Unit Testing - Software Engineering at Google

2022. 7. 7. 21:05ETC/Software Engineering

본 포스팅은 Software Engineering at Google의 Code Review Flow 내용을 정리한 내용입니다.

 

해당 내용은 O'reilly에서 출간하고, 저자에 의해 공개된 Software Engineering at Google을 바탕으로 참고하여 정리한 내용입니다.

내용이 재밌기도 하고 기록하고 싶은 내용이 많아서 몇 가지 정리해볼 예정입니다.

 

테스트와 관련된 참고 내용이 많아서 특히 오래 걸렸네요 ,,

하지만, 그만큼의 가치가 충분한 내용이어서 읽기를 추천드리고 싶습니다 〰️ 

 

 


 

Unit Testing

 

탐색적 테스팅 exploratory testing 은 테스트 대상을 고장내야할 퍼즐로 생각한다.

기본적으로 창의력을 요구하는 작업이다.

의외의 데이터를 입력하거나 예상치 못한 절차로 조작하여 망가뜨리려 시도한다.

 

구글이 테스트를 분류하는 두 가지 주요 축은 '크기'와 '범위'이다.

'크기 size'는 테스트가 소비하는 자원과 수행할 수 있는 작업을 뜻하며,

'범위 scope'는 테스트가 검증하고자 하는 코드의 양을 의미한다.

 

테스트 크기의 정의는 명확하지만 범위는 다소 모호한 면이 있다.

구글에서 말하는 단위 테스트unit test 는 단일 클래스나 메서드처럼 범위가 상대적으로 좁은 테스트를 뜻한다.

단위 테스트는 일반적으로 크기가 작은데, 사실 반드시 그런 것만은 아니다.

 

 

Unit Tests Properties

✔️ 대체로 작은 테스트로, 빠르고 결정적deterministic 👉🏻 수시로 수행하며 피드백을 즉각 얻을 수 있다.

✔️ 대체로 대상 코드와 동시에 작성할 수 있을 만큼 작성하기 쉽다.

✔️ 기존 동작을 망가뜨리지 않게끔 커버러지를 높일 수 있다.

✔️ 간단한 개념이며 시스템의 특정 부분에 집중적인 코드  👉🏻  실패 시 원인을 파악하기 쉽다.

✔️ 테스트 대상의 사용법과 의도 동작 방식을 알려주는 문서자료나 예제 코드 역할을 해준다.

 

 

구글은 유지보수성을 상당히 중요시하는데, 유지 보수하기 쉬운 테스트란 'just work, 그냥 동작하는' 테스트를 의미한다.

한 번 작성해두면 실패하지 않는 한 신경쓸 필요가 없고, 실패해도 명확하게 이유로 버그를 찾을 수 있다는 의미다. 

 

 

 

Maintainability

본 포스팅에서는 주로 테스트의 메인 코드가 변경되어도 테스트 코드가 변경되지 않는 유지 가능성의 중요함을 다룬다.

유지 가능성의 팁으로 쉽게 깨지지 않는, 명확한 코드 작성을 제안한다.

먼저, 그 중요성에 대해 알아보자.

 

 

Mary는 간단한 기능 하나를 추가하려고 하는데, 코드 10줄 정도면 충분하여 순식간에 구현할 수 있었다.
그런데 체크인하려 하자 자동 테스트 시스템이 화면 가득 오류를 보내왔다.

Mary는 하나하나 실패 테스트들을 해결하느라 남은 하루를 다 허비했다. 각각의 테스트 케이스 마다 실패 이유는 진짜 버그가 아니었다. 테스트하려는 코드의 내부 구조가 특정한 형태일 때만을 가정하였는데, 그 부분이 깨져서 테스트 코드를 변경해야 하는 것이었다.

문제는 테스트 코드는 대부분 무엇을 검증하려는지가 한눈에 들어오지 않았다. 게다가 이 문제를 해결하려고 추가한 코드 때문에 나중에 이 테스트를 들여다볼 사람이 로직을 이해하기가 더 어려워졌다.

결국 금방 끝나야 했을 작업 하나 때문에 Mary는 며칠을 허비하며 생산성과 사기가 크게 떨어졌다.

 

 

 

이 문제에서 테스트가 정반대의 효과를 냈다. 하지만 주변에서 흔히 볼 수 있는 문제이다.

이 문제를 해결할 만병통치약은 없지만, 구글에서는 해결할 다양한 패턴과 관행을 발굴하고 공유하여 권장해오고 있다.

 

위의 내용에서는 두 가지를 문제로 삼을 수 있다.

 

첫째, 버그도 검증 대상과도 관련없는 '깨지기 쉬운Brittle' 테스트

둘째, 원인을 찾기 어려운 '불명확한unclear' 테스트

 

이 두 문제점을 기반으로 어떻게 코드를 짜야할지에 대한 권장 사항들을 알아보자. 

 

 

 

Preventing Brittle Tests

 

Brittle Tests

깨지기 쉬운 테스트

: 실제로 버그는 없지만, 검증 대상 코드와는 관련조차 없는 변경 때문에 실패하는 테스트

 

 

변경되지 않게 하자

메인 코드의 변경에 따라 변경되지 않는 테스트를 작성하기 위해서는,

메인 코드가 변경되는 유형을 생각해보면서 각 유형 별로 테스트를 어떻게 가져가야할지 따져봐야 한다.

 

✔️ when Pure refactorings

인터페이스의 수정없이 내부만을 리팩터링한다면 테스트는 변경되면 안된다.

이 때 테스트의 역할은 해당 기능의 행위가 달라지지 않음을 보장하는 것이다.

 

만약 테스트를 변경할 상황이 생긴다면,

기능의 행위가 변경되었거나 테스트 추상화 수준이 적절하지 않음을 나타낸다.

 

 

✔️ when New features

새로운 기능은 기존 테스트에 영향을 주면 안된다. 

새 기능 당 새로운 테스트를 작성해야 한다.

 

만약 테스트를 변경할 상황이 생긴다면,

다른 기능에 의도치 않은 영향을 기쳤거나 테스트 자체에 문제가 있음을 나타낸다.

 

 

✔️ when Bug fixes

새로운 기능과 비슷하게, 버그가 발생했다는 것은 기존 test suite에 빠진 기능이 있고, 해당 케이스를 놓쳤기 때문이다.

따라서 누락된 케이스를 추가해야 한다.

 

 

✔️ when Behavior changes

기존 기능을 변경하는 경우로, 기존 테스트도 함께 변경되어야 한다.

이 때에는 위와 달리 '위도치 않게' 변경하는 것이 아니라 '의도적으로' 변경하는 것이다.

시스템을 망가뜨리지 않게, 혹은 애초부터 변경할 일이 없게 설계하려고 노력해야 한다.

 

 

 

Public API를 사용하자

 

Public API를 사용하면 정의상 대상 시스템을 사용자와 똑같은 방식으로 사용한다.

그래서 더 현실적이고 잘깨지지 않는다. 

 

Public API 를 구별하는 정의는 없지만, 구글의 경험 상 그 법칙은 아래와 같다.

 

✔️ 보조 클래스는 테스트 X

: 보조의 용도(헬퍼 클래스)라면 독립된 단위로 생각하지 않는게 좋다.

해당 클래스가 보조하는 클래스를 우회적으로 테스트하는 것이 좋다.

 

✔️ 누구든 접근할 수 있으면 테스트 O

: 누구든 접근할 수 있는 패키지 혹은 클래스는 거의 예외없이 직접 사용자와 똑같은 방식으로 접근하는 테스트를 하는 것이 좋다. 

 

✔️ 넓은 범위라면 중복이 되더라도 테스트 O

: 넓은 범위로 기능을 제공하는 패키지나 클래스(support library)도 직접 테스트하는 단위로 보자.

중복이 될 수 있지만, 유익한 중복이다. 

 

 

 

 

위와 같은 코드를 테스트 할 때 아래와 같이 private으로 되어있는 메서드에 접근하고 싶은 유혹이 생길 것이다.

 

 

#1. bad
#2. good

 

#1과 같은 테스트 케이스를 짜게 되면 내부 메서드를 조금만 수정해도 금방 깨지게 될 것이다.

하지만 아래와 같이 public API만을 사용해서 같은 수준의 테스트 커러비지를 달성할 수 있다.

 

 

 

 

Test State, Not Interactions

 

State testing

: you observe the system itself to see what it looks like after invoking with it.

메서드 호출 후 발생하는 결과 자체를 확인

 

Interaction testing

: you instead check that the system took an expected sequence of actions on its collaborators in response to invoking it.

입력한 값에 대해 그 호출 값을 불러오며 사용되는 기능이 잘 수행하는지 확인

 

번역이 어색해서 추가하자면 State는 메서드를 호출 결과값의 상태를 체크하고,

Interaction은 테스트와 관련된 호출 메서드가 잘 동작하는지 확인한다.

 

 

#1. Brittle interaction test
#2. Testing against state

 

 

#1에서는 특정 API가 호출되었는지를 확인한다. 

만약, 리팩터링이 되거나 쓰이고 나서 버그로 인해 데이터가 삭제되어도 통과할 것이다.

 

#2는 호출 후 어떤 상태인지를 판단하며, 관심사를 더 명확하게 표현하고 있다.

 

 

 

 

 

Writing Clear Tests

완전하고, 간결하게

Complete test 완전한 테스트 : 읽는 이가 이해하는데 필요한 모든 정보를 본문에 담고 있는 테스트

Concise test 간결한 테스트 : 코드가 산만하지 않고 관련없는 정보는 포함하지 않은 테스트

 

아래는 이 두가지를 모두 못지킨 예시이다.

 

bad

 

입력 값의 의미를 명확하게 바꿔주고, 계산기 생성과 관련없는 내용은 숨겨주어 간결하게 만들어보자.

 

good

 

 

 

행위 주도 테스트

행위 주도 테스트Behavior-driven tests는 대체로 메서드 중심 테스트Method-oriented tests보다 명확하다.

그 이유는 아래와 같이 세 가지 정도 들 수 있다.

 

#1. 자연어에 더 가깝기 때문에 더 자연스럽게 이해할 수 있다.

#2. 테스트 각각이 더 좁은 범위를 검사하기 때문에 원인과 결과가 더 분명하게 드러난다.

#3. 각 테스트는 짧고 서술적이기 때문에 검사한 기능이 무엇인지 파악하는 게 쉽다. 덕분에 다른 엔지니어가 기존 메서드에 코드를 추가하지piling 않고 새로운 메서드를 추가하게끔 이끈다.

 

많은 개발자들이 메서드 하나에 테스트 메서드 하나씩 두려고Method-oriented tests 한다. 

가령 아래와 같은 메서드를 정의했다고 해보자.

 

상황에 따라 두 가지의 메세지를 보여준다

 

그럼 아래와 같은 테스트 코드를 짜려고 할 것이다.

 

#1. bad
#2. good

 

 

#1와 같이 메서드 하나를 검사하다보면 불명확한 테스트로 이어지는 문제가 발생한다.

테스트를 메서드 별로 작성하지 말고, 아래와 같이 행위별로 작성하는 방법을 추천한다.

 

결과적으로 #2와 같이 변경할 수 있다.

테스트를 쪼개느라 코드가 늘어났지만, 훨씬 명확하다는 가치가 있다.

 

 

행위란 특정 상태인 해당 시스템이 입력에 대해서 어떻게 반응할지에 대한 어떠한 보증guarantee과 같다.

입력 값에 대한 결과값을 예상할 수 있는 것을 '보증'할 수 있는 것으로 표현한 듯 합니다.

 

행위를 표현하는 방법에는 대표적으로 "given", "when", "then"이 있다.

가령, 아래와 같이 표현할 수 있다.

 

(given) 은행 잔고가 빈 상태에서
(when) 돈을 인출하려하면
(then) 거래를 거부한다. 

 

모든 행위는 given, when, then이라는 세 요소로 구성된다.

 

given : 설정 정의

when : 시스템이 수행할 작업을 정의

then : 결과를 검증

 

 

이 요소들이 명확히 드러나는 구조라면 명확하고 깔끔한 테스트라고 할 수 있다.

그래서 Cucumber이나 Spock 처럼 given/when/then을 지원하는 프레임워크도 생겼다.

 

Cucumber.io

 

이렇게 테스트 코드를 작성하게 되면 아래 세 단계의 깊이로 점차 자세하게 파악할 수 있다. 

기본적으로 아래와 같이 주석을 통해 간단히 명시하는 것이 좋다.

 

 

 

행위 중점일 때 세 단계를 통해 테스트를 자세히 이해해 나갈 수 있다.

 

1. 메서드의 이름으로 검사 행위를 간략하게 파악

2. 행위를 형식화해 설명한 given/when/then으로 파악

3. 주석의 설명이 실제 코드로는 정확한 표현 확인

 

 

꽤 복잡한 테스트를 진행할 때, 코드 사이사이 Assertion이 등장해서 위 패턴을 무너뜨리기도 한다.

이럴 때는 아래와 같이 When과 Then을 교대로 정의할 수 있다.

또, 블록이 길어질 때 And 접속사를 사용하면 더 잘 읽힌다.

 

 

 

테스트에서의 이름은 굉장히 중요하다.

테스트의 의도를 표현하는 방법이기도 하고, 실패 시 테스트의 이름만 있다면 이름만으로 그 기능을 파악할 수 있어야 하기 때문이다.

 

테스트의 이름은 검사하려는 행위를 요약해서 보여줘야 한다.

시스템이 수행하는 동작과 예상 결과를 모두 담아야 좋은 이름이다. 

관련 TotT 링크

 

multiplyingTwoPositiveNumbersShouldReturnAPositiveNumber
multiply_positiveAndNegative_returnsNegative
divide_byZero_throwsException

 

위와 같이 아주 복잡하고 긴 이름은 production code에서는 지양되지만, 테스트 코드에서는 괜찮다.

다양한 방식의 이름 짓기 전략을 사용해도 좋고, 좋은 전략이 없다면 'should'로 시작하는 이름을 사용해보아라.

 

shouldNotAllowWithdrawalsWhenBalanceIsEmpty
-> "BankAccount should not allow withdrawals when balance is empty." 

 

 

 

로직을 넣지 마라

 

논리는 프로그래밍 언어에서 연산자, 반복문, 조건문 등을 이용해 표현하는데,

이 논리가 포함된 코드의 결과를 예상하는 것이 쉽지만은 않다.

 

테스트에서는 이러한 논리가 조금만 들어가도 추론하기가 어려워진다.

 

bad

 

이 간단한 테스트에서 감춰진 버그가 보이는가?

 

why - bad

 

전체 문자열을 바로 적으니, 버그가 바로 보인다.

중복된 코드가 있더라도, 서술적이고 의미 있는 테스트를 얻게된 가치가 있다.

 

 

 

명확한 실패 메세지

실전에서는 테스트 실패 보고서나 로그에 찍힌 메시지 한 줄만으로 문제의 원인을 찾아내야 할 때가 많다.

 

잘 작성된 실패 메시지라면 테스트의 이름과 거의 동일한 정보를 담고 있어야 한다.

즉, ‘원하는 결과’, ‘실제 결과’, ‘이때 건네진 매개변수의 값’을 명확히 알려줘야 한다.

 

 

좋은 실패 메시지는 기대한 상태와 실제 상태를 명확히 구분해주고, 결과가 만들어진 맥락 정보도 제공하는 것이 좋다.

구글이 개발한 Assertion 라이브러리인 Truth를 Junit과 비교해보자.

 

 

 

 

 

 

DAMP, Not DRY

 

DAMP — "Descriptive And Meaningful Phrases."

DRY    — "Don’t Repeat Yourself"

 

테스트 코드는 보통 소프트웨어 코드와는 다르게 "반복하지 말라"라는 규칙의 DRY의 혜택이 그리 크지 않다.

오히려 "서술적이고 의미있는 구문"인 DAMP가 되도록 해야한다.

 

DAMP는 DRY의 대체가 아니라 '보완'의 개념이다.

검사 행위와 관련없는 반복코드를 추상화할 수 있는데, 여기서 핵심은 테스트의 리팩터링은 반복을 줄이는 게 아니라 더 서술적이고 의미있게 하는 방향으로 이뤄져야 한다는 것이다.

 

 

DRY
DAMP

 

 

 

코드를 여러 테스트에서 공유하는 일반적인 패턴에는 아래와 같다.

 

 

✔️ Shared Values

각 테스트에서 사용되는 객체가 왜 그 값을 선택했는지 모호할 때가 있다.

 

#1. bad
#2. good

 

#1 에서는 CLOSED_ACCOUNT 나 ACCOUNT_WITH_LOW_BALANCE 와 같은 이름을 줄 수는 있지만, 세부 정보를 알기는 어렵다. 

#2 와 같이 자바에서는 위와 같이 Builder와 같이 정의해서 사용할 수 있다.

객체를 정의함에 있어 간결하게 짜는 것이 좋다는 일반적인 DRY원칙과는 다르게 서술을 통해 객체의 속성을 파악하게 하여 테스트의 이해를 돕고 있다.

 

 

✔️ Shared Setup

셋업 객체는 하나의 test suite에 속한 테스트를 각각 수행하기 직전에 실행되는 메서드를 정의할 수 있게 해준다. 셋업 메서드는 대상 객체와 협력 객체 collavorator들을 생성하는 데 매우 유용하다.

어떤 인수를 넣는지 관심이 없고, 테스트 수행 후에도 객체 상태가 전혀 변하지 않는다면 매우 유용한 방법이다.

 

 

#1. bad
#2. good

 

#1에서 Donald Knuth 이름은 도대체 어디서 왔는지 찾기가 어려워진다.

#2와 같이 setUp에서 설정한 userStore에서 이름을 가져와 비교할 수 있다.

 

 

✔️ Shared Helpers and Validation

하나의 목적에 집중하는 검증 메서드 validation method 는 테스트에서도 유용하다.

검증용 메서드(Helper Method)는 여러 조건을 확인하는 게 아니라 입력에 대한 단 하나의 ‘개념적 사실’만을 검증해야 한다.

 

개념적으로는 단순하지만 그 개념을 검사하는 로직이 복잡한 경우라면 특히 큰 도움이 된다.

예를 들어 검증 로직에 반복문이나 조건문이 들어가서 테스트 메서드의 명확성을 떨어뜨리는 경우라면 검사 로직이 복잡하다고 말할 수 있다.

가령 아래의 헬퍼 메서드는 계정 접근 권한과 관련한 여러 테스트에서 유용할 것이다.

 

 

 

 

 

Test Infrastructure

테스트에서 코드를 공유할 때 하나의 클래스나 스위트에 속한 메서드들 사이의 범위였다. 

그런데 가끔 다른 Test Suite와 코드를 공유하면 유용할 때가 있다. 

구글은 이런 코드를 테스트 인프라 test infrastructure 라고 부른다. 

 

테스트 인프라는 주로 통합 테스트나 종단간 end-to-end 테스트 때 빛을 발한다. 

그리고 신중하게 설계한다면 특정 상황에서는 단위 테스트를 작성하는 데도 큰 도움을 준다.

 

테스트 인프라는 많은 곳에서 호출되는 만큼 이에 의존하는 코드가 많은데, 때문에 변경하기도 어렵다. 

달리 말하면 일반적인 테스트 코드보다는 제품 코드와 비슷한 특성을 보인다. 

테스트 인프라는 독립된 제품처럼 간주해야 하며, 그 자체를 검사할 ‘자체 테스트들을 갖추고 있어야’ 한다.