Home 12. 단위 테스트
Post
Cancel

12. 단위 테스트

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

테스트의 가장 중요한 목적은 버그 예방이다. 그 다음으로는 엔지니어의 생산성 개선이 있다. 범위가 넓은 테스트와 비교했을 때 단위 테스트는 생산성을 끌어올리는 훌륭한 수단이 될 수 있다.

  • 단위 테스트는 대체로 작은 테스트에 속한다. 작은 테스트는 빠르고, 결정적이여서 수시로 수행하여 즉각적인 피드백을 받을 수 있다.
  • 단위 테스트는 작성하기 쉽다.
  • 빠르게 작성할 수 있어 테스트 커버리지를 높이기 좋다. 기존 동작을 망가뜨리지 않으리라는 확신 속에서 코드를 변경할 수 있다.
  • 실패시 원인을 파악하기 쉽다.
  • 대상 시스템의 사용법과 의도한 동작 방식을 알려주는 문서자료 혹은 예제 코드 역할을 해준다.

단위 테스트는 일상에서 비중이 크기 때문에 구글은 테스트 유지보수성을 상당히 중시한다. 유지보수하기 쉬운 테스트란 한 번 작성해두면 실패하지 않는 한 엔지니어가 신경 쓸 필요없고, 실패한다면 원인을 바로 알 수 있는 진짜 버그를 찾았다는 뜻이다.

12.1 유지보수하기 쉬워야 한다.

주변에서 흔히 목격되는 테스트 작성의 문제의 원인으로

  1. 버그도 없고 자신의 검증 대상과 관련없는 변경 때문에 실패하는 깨지기 쉬운 테스트들이 도사리고 있다.
  2. 무엇이 잘못되어 실패했는지 어떻게 고쳐야 하는지 파악하기 어려운 불명확한 테스트들이다.

12.2 깨지기 쉬운 테스트 예방하기

깨지기 쉬운 테스트란 실제로는 버그가 없음에도, 검증 대상 코드와는 관련조차 없는 변경 때문에 실패하는 테스트를 말한다. 이러한 테스트는 엔지니어가 직접 진단하고 수정해야 한다.

12.2.1 변하지 않는 테스트로 만들기 위해 노력하자

기본적인 변경 유형은 다음과 같다.

  • 순수 리팩터링 - 외부 인터페이스는 놔두고 내부만 리팩터링 하는 경우 테스트는 변경되지 않아야 한다. 리팩터링 과정에서 테스트를 변경해야 한다면 원인은 1)시스템의 행위가 달라졌다, 2)테스트의 추상화 수준이 적절하지 않았다.
  • 새로운 기능 추가 - 새로운 기능이나 행위를 추가할 때는 기존 행위들에 영향을 주지 않아야 한다. 기존 테스트를 변경해야 한다면 해당 테스트가 검증하는 기능에 의도치 않은 영향을 주었거나 테스트 자체에 문제가 있다는 뜻이다.
  • 버그 수정 - 버그 수정은 새로운 기능 추가와 비슷하다.
  • 행위 변경 - 시스템의 기존 행위를 변경하는 경우로 기존 테스트 역시 변경되어야 한다.

리팩터링, 새 기능 추가, 버그수정시에는 기존 테스트를 손볼 일이 없어야 한다.

12.2.2 공개 API를 이용해 테스트하자

테스트가 시스템을 사용자와 똑같은 방식으로 사용하게 하면 테스트가 실패한 경우 사용자도 똑같은 문제를 겪는다. 그렇게 되면 테스트가 사용자에게 유용한 예제 코드와 문서자료가 되어준다는 이점을 얻을 수 있다.

어디까지가 공개 API냐가 항상 명확한 것은 아니며 이는 단위 테스트에서 말하는 단위가 무엇이냐를 규정하는 핵심적인 질문으로 이어진다. 이런 맥락에서 공개 API란 이런 단위의 코드 소유자가 서드파티에 노출한 API를 뜻한다.(??)

어디까지가 공개 API인가를 정하는 일에 정답은 없지만 경험법칙으로 다음과 같이 있다.

  • 소수의 다른 클래스를 보조하는 용도가 다인 메서드나 클래스는 직접 테스트하지 말고 이들을 보조하는 클래스를 통해 우회적으로 테스트해야 한다.
  • 소유자의 통제 없이 누구든 접근할 수 있게 설계된 패키지나 클래스라면 예외없이 직접 테스트해야하는 단위로 취급해야 한다.
  • 소유자만이 접근할 수 있지만 다방면으로 유용한 기능을 제공하도록 설계된 패키지나 클래스 역시 직접 테스트해야 하는 단위로 본다.

12.2.3 상호작용이 아니라 상태를 테스트하자

시스템이 기대한대로 동작하는지 검증하는 방법으로 다음과 같이 있다.

  1. 상태 테스트 - 메서드 호출 후 시스템 자체를 관찰한다.
  2. 상호작용 테스트 - 호출을 처리하는 과정에서 시스템이 다른 모듈들과 협력해서 기대한 일련의 동작을 수행하는지를 확인한다.

대체로 상호작용 테스트가 상태 테스트보다 깨지기 쉽다. 우리가 원하는 것은 결과가 무엇이냐 이지만, 상호작용 테스트는 결과에 도달하기까지 시스템이 어떻게 작동하냐를 확인하려 들기 때문이다.

잠재적으로 문제가 될 수 있는 상호작용 테스트가 만들어지는 가장 큰 원인은 바로 모의 객체 프레임워크에 지나치게 의존하기 때문이다. 모의 객체 프레임워크를 이용하면 테스트 대역을 만들기 쉽고, 이는 자신을 향한 모든 호출을 기록하고 검증할 수 있게 해준다. 이러한 편리함에 엔지니어들에게 깨지기 쉬운 상호작용 테스트를 만들도록 유혹한다.

그래서 우리는 진짜 객체가 빠르고 결정적이라면 진짜 객체를 사용해야 한다.

12.3 명확한 테스트 작성하기

깨지기 쉬운 요소를 제거했더라도 언젠가는 테스트가 실패한다. 실패하는 이유로는

  1. 대상 시스템에 문제가 있거나 불완전한 경우
  2. 테스트 자체에 결함이 있는 경우

테스트 실패의 이유를 얼마나 빠르게 찾느냐는 테스트 명확성에 달려있다. 명확한 테스트는 존재 이유와 실패 원인을 엔지니어가 곧바로 알아차릴 수 있는 테스트를 말한다. 명확한 테스트는 대상 시스템의 문서자료 역할을 해주고, 새로운 테스트를 작성하기 쉽게 도와주는 토대가 되어주는 등의 이점도 제공한다.

제품의 코드가 명확하지 않은 문제와는 다르다. 대체로 제품 코드는 해당 코드를 제거했을 때 어디에 문제가 생기는지나 호출 관계 등을 파헤쳐보면 비교적 쉽게 목적을 알아낼 수 있다. 반면 불명확한 테스트는 목적을 결코 알아내지 못할 가능성도 크다.

12.3.1 완전하고 간결하게 만들자

완전하고 간결성은 테스트를 명확하게 만드는 데 도움이 되는 거시적인 특성이다. 완전한 테스트는 결과에 도달하기까지의 논리를 읽는 이가 이해하는 데 필요한 모든 정보를 본문에 담고 있는 테스트를 말한다. 간결한 테스트는 코드가 산만하지 않고 관련 없는 정보는 포함하지 않는 테스트이다.

[두 특성 중 어느 것도 갖추지 못한 안 좋은 테스트 예]

1
2
3
4
5
6
7
8
9
@Test
public void shouldPerformAddition() {
  //Calculator 생성자에서 관련 없는 정보를 잔뜩 받고 있다.
  Calculator calculator = new Calculator(new RoundingStrategy(), "unused", ENABLE_COSINE_FEATURE, 0.01, calculusEngine, false);

  //중요한 부분은 도우미 메서드인 newTestCalculation에 숨겨져 있다.
  int result = calculator.calculate(newTestCalculation());
  assertThat(result).isEqualsTo(5);
}

[완전하고 간결한 테스트]

1
2
3
4
5
6
@Test
public void shouldPerformAddition() {
  Calculator calculator = new Calculator();
  int result = calculator.calculate(newCalculation(2, Operation.PLUS, 3));
  assertThat(result).isEqualsTo(5);
}

12.3.2 메서드가 아니라 행위를 테스트하자

많은 엔지니어가 본능적으로 테스트의 구조를 대상 코드의 구조와 일치시키려고 한다. (제품 코드의 메서드 하나에 테스트 메서드도 하나씩 두는 식으로) 이렇게 되면 대상 메서드가 복잡해질수록 테스트도 함께 복잡해져서 실패해도 원인을 파악하기 어려워진다.

1
2
3
4
5
6
public void displayTransactionResult(User user, Transaction transaction) {
  ui.showMessage(transaction.getItemName() + "을(를) 구입하셨습니다.");
  if(user.getBalance() < LOW_BALANCE_THRESHOLD) {
    ui.showMessage("잔고가 부족합니다!");
  }
}

[메서드 중심 테스트]

1
2
3
4
5
6
7
@Test
public void testDisplayTransactionResult() {
  transactionProcessor.displayTransactionResult(newUserWithBalance(LOW_BALANCE_THRESHOLD.plus(dollars(2))), new Transaction("물품", dollers(3)));

  assertThat(ui.getText()).contains("물품을(를) 구입하셨습니다.");
  assertThat(ui.getText()).contains("잔고가 부족합니다!");
}

[행위 주도 테스트]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testDisplayTransactionResult_showsItemName() {
  transactionProcessor.displayTransactionResult(new User(), new Transacion("물품"));
  assertThat(ui.getText()).contains("물품을(를) 구입하셨습니다.");
}

@Test
public void testDisplayTransactionResult_showLowBalanceMarning() {
  transactionProcessor.displayTransactionResult(
          newUserWithBalance(LOW_BALANCE_THRESHOLD.plus(dollars(2))
          ), new Transaction("물품", dollers(3))
          );
  assertThat(ui.getText()).contains("잔고가 부족합니다!");
}

테스트를 쪼개느라 코드가 늘어났지만 각 테스트가 명확하게 되었으니 결과적으로 그 이상의 값어치를 한 것이다. 행위 주도 테스트는 대체로 메서드 중심 테스트보다 명확하다. 이유는

  1. 자연어에 더 가깝게 읽히기 때문에 힘들여 분석하지 않아도 자연스럽게 이해할 수 있다.
  2. 테스트 각각이 더 좁은 범위를 검사하기 때문에 원인과 결과가 더 분명하게 드러난다.
  3. 각 테스트가 짧고 서술적이어서 이미 검사한 기능이 무엇인지 더 쉽게 확인할 수 있다.

테스트의 구조는 행위가 부각되도록 구성하자

모든 행위는 given, when, then 이라는 세 요소로 구성됨을 기억하자

  • given - 시스템의 설정을 정의 (예. 은행 잔고가 빈 상태에서)
  • when - 시스템이 수행할 작업을 정의 (예. 돈을 인출하려 하면)
  • then - 결과를 검증 (예. 거래를 거부한다.)
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void transferFundsShouldMoveMoneyBetweenAccount() {
  //given 두 개의 계좌, 각각의 잔고는 150과 20
  Account account1 = newAccountWithBalacve(usd(150));
  Account account2 = newAccountWithBalacve(usd(20));

  //when 첫 번째 계좌에서 두 번째 계좌로 100 이체
  bank.transferFunds(account1, account2, usd(100));

  //then 각 계좌 잔고에 이체결과 반영
  assertThat(account1.getBalance()).isEqualTo(usd(50));
  assertThat(account2.getBalance()).isEqualTo(usd(120));
}

여러 단계로 진행되는 작업을 단계별로 검증하고 싶을 때는 when과 then 블록을 교대로 정의하는 방법도 있다. 이런 테스트를 작성할 때는 동시에 여러 행위를 검사하는 실수를 범하지 않도록 주의해야 한다. 테스트 각각은 단 하나의 행위만 다뤄야 한다.

테스트 이름은 검사하는 행위에 어울리게 짓자

메서드 중심 테스트들의 이름은 대체로 대상 메서드의 이름을 따서 짓는다. 반면 행위 주도 테스트에서는 이름 짓기가 더 자유롭기 때문에 더 의미 있는 정보를 담을 수 있다.

테스트의 이름은 검사하려는 행위를 요약해 보여줘야 한다. 시스템이 수행하는 동작과 예상 결과를 모두 담아야 좋은 이름이다.

메서드 이름에 전부 녹이게 되면 상당히 상세하고 길어진다. 제품에 들어갈 코드였다면 추천하지 않지만 우리는 이 메서드를 호출하는 코드를 작성할 일이 없다. 대신 이 이름들을 주로 보고서에 담겨서 사람에게 전달될 것이다.

12.3.3 테스트에 논리를 넣지 말자

복잡성은 대체로 논리라는 형태로 나타난다. 논리는 프로그래밍 언어에서 명령형 요소(연산자, 반복문, 조건문 등)를 이용해 표현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//논리가 버그를 감추는 예
@Test
public void shouldNavigateToAlbumsPage() {
  String baseUrl = "http://photos.google.com/";
  Navigator nav = new Navigator(baseUrl);
  nav.goToAlbumPage();
  assertThat(nav.getCurrentUrl()).isEqualTo(baseUrl + "/albums");
}

@Test
public void shouldNavigateToAlbumsPage() {
  Navigator nav = new Navigator(http://photos.google.com/);
  nav.goToAlbumPage();

  //전체 문자열을 적어주자 URL에 슬래시를 두 번 썼음이 바로 드러났다.
  assertThat(nav.getCurrentUrl()).isEqualTo("http://photos.google.com//albums"); // ???
}

위 예처럼 단순한 문자열 연결에서도 버그를 잡아내기 쉽지 않은데 반복문이나 조건문처럼 더 복잡한 로직이 들어갔다면 말할 것도 없다. 테스트 코드에서는 스마트한 로직보다 직설적인 코드를 고집해야 한다. 또 서술적이고 의미 있는 테스트를 만들기 위한 약간의 중복은 허용하는 것이 좋다.

12.3.4 실패 메시지를 명확하게 작성하자

실무에서는 테스트 실패 보고서나 로그에 찍힌 메시지 한 줄로만 문제의 원인을 찾아야 할 때가 많다. 잘 작성된 실패 메시지라면 테스트의 이름과 거의 동일한 정보를 담고 있어야 한다. 원하는 결과, 실제 결과, 이때 건네진 매개변수의 값을 명확히 알려줘야 한다.

12.4 테스트와 코드 공유: DRY가 아니라 DAMP!

테스트를 명확하고 잘 깨지지 않게 해주는 마지막 요인은 코드 공유라는 주제와 관련이 있다. 대부분의 소프트웨어는 반복하지 말라는 뜻의 DRY 원칙을 숭배한다.

DRY는 개념들을 각각 독립된 하나의 장소에서 구현하여 코드 중복을 최소로 줄이면 유지보수하기 더 쉽다고 말한다. 이 원칙대로 한다면 참조에 참조를 따라야 실제 로직을 구현한 코드를 찾아 분석할 수 있기 때문에 코드 명확성이 떨어진다는 단점도 생긴다.

좋은 테스트는 안정적이고, 대상 시스템의 행위가 변경되면 실패하도록 설계된다. 따라서 테스트코드에서는 DRY가 주는 혜택이 그리 크지 않다. 테스트 코드는 DRY를 고집하는 대신, DAMP가 되도록 노력해야 한다. DAMP는 ‘서술적이고 의미 있는 문구’를 뜻한다. 단순하고 명료하게만 만들어준다면 테스트에서 다소의 중복은 괜찮다.

테스트에서의 리팩터링은 반복을 줄이는 게 아니라 더 서술적이고 의미 있게 하는 방향으로 이루어져야 한다.

12.4.1 공유 값

[이름이 모호한 공유 값]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static final Account ACCOUNT_1 = Account.newBuilder().setState(AccountState.OPEN).setBalance(50).build();

private static final Account ACCOUNT_2 = Account.newBuilder().setState(AccountState.CLOSED).setBalance(0).build();

private static final Item ITEM = Item.newBuilder().setName("치즈버거").setPrice(100).build();

// 수백 줄의 다른 테스트들....

@Test
public void canBuyItem_returnsFalseForCloserdAccounts() {
  assertThat(store.canBuyItem(ITEM, ACCOUNT_1)).isFalse();
}

@Test
public void canBuyItem_returnsFalseWhenBalanceInsufficient() {
  assertThat(store.canBuyItem(ITEM, ACCOUNT_2)).isFalse();
}

위 예는 테스트 스위트가 커질수록 문제가 된다. 각 테스트에서 왜 그 값을 선택했는지를 이해하기 어렵다.

다음의 예는 도우미 메서드를 이용하여 데이터를 구성하는 방법을 사용한다. 테스트 작성자가 필요한 값들만 명시해 도우미 메서드에 요청하면 그 외 값들에는 적절한 기본값을 설정해 돌려준다.

[도우미 메서드를 사용해 값을 공유하는 예]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 도우미 메서드는 각 매개변수에 임의의 기본 값을 정의하여 생성지를 래핑한다.
def newContact(firstName = "Grace", lastName="Hopper", phoneNumber="555-123-4567"):
  return Contact(firstName, lastName, phoneNumber)

// 테스트는 필요한 매개변수의 값만 설정하여 도우미를 호출한다.
def test_fullNameShouldCombineFirstAndLastNames(self):
  def contact = newContact(firstName="에이다", lastName="러브레이스")
  self.assertEqual(contact.fullName(), "에이다 러브레이스")

private static Contact.Builder newContact() {
  return Contact.newBuilder()
      .setFirstName("그레이스")
      .setLastName("호퍼")
      .setPhoneNumber("555-123-4567");
}

@Test
public void fullNameShouldConbineFirstAndLastNames() {
  Contact contact = newContact()
    .setFirstName("에이다")
    .setLastName("러브레이스")
    .build();

  assertThat(contact.getFullName()).isEqualTo("에이다 러브레이스");
}

이처럼 도우미 메서드를 이용하면 불필요한 정보로 오염되거나 다른 테스트와 출돌할 염려 없이 정확히 필요한 값들만 생성해 사용할 수 있다.

12.4.2 공유 셋업

테스트 각각을 수행하기 직전에 실행되는 메서드를 정의할 수 있게 해주는 것을 ‘셋업’ 메서드라고 한다 셋업 메서드는 대상 객체와 협력 객체들을 생성하는 데 매우 유용하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//셋업 메서드에서 사용한 값에 의존
@Before
public void setUp() {
  nameService = new NameService();
  nameService.set("user1", "도널드 커누스");
  userStore = new UserStore(nameService);
}

//.....

@Test
public void shouldReturnNameFromService() {
  UserDetails user = userStore.get("user1");
  assertThat(user.getName()).isEqualTo("도널드 커누스");
}


//셋업 메서드에서 사용한 값을 덮어씀
@Before
public void setUp() {
  nameService = new NameService();
  nameService.set("user1", "도널드 커누스");
  userStore = new UserStore(nameService)l
}

@Test
public void shouldReturnNameFromService() {
  nameService.set("user1", "마거릿 해밀턴");
  UserDetails user = userStore.get("user1");
  assertThat(user.getName()).isEqualTo("마거릿 해밀턴");
}

12.4.3 공유 도우미 메서드와 공유 검증 메서드

도우미 메서드를 이용하면 테스트용 값을 생성하는 코드가 간결해진다. 하지만 모든 테스트가 마지막에 도우미 메서드를 호출하는 극단적인 상황은 위험한 습관이다.

하나의 목적에 집중하는 검증 메서드는 유용하다. 검증용 도우미 메서드는 여러 조건을 확인하는 게 아니라 입력에 대한 단 하나의 개념적 사실만을 검증한다.

12.4.4 테스트 인프라 정의하기

가끔은 테스트 스위트와도 코드를 공유하면 유용할 때가 있다. 구글은 이런 종류의 코드를 테스트 인프라라고 부른다. 테스트 인프라는 주로 통합 테스트나 종단간 테스트 때 빛을 발한다.

테스트 인프라는 단일 테스트 스위트 안에서의 코드 공유보다 훨씬 신중하게 접근해야 한다. 테스트 인프라는 많은 곳에서 호출되는 만큼 이에 의존하는 코드가 많다. 그래서 동작이 달라지면 다른 코드들이 깨지기 때문에 변경하기도 어렵다.

12.5 마치며

단위 테스트는 예기치 못한 변경에도 시스템이 오래도록 잘 구동되게끔 보증하기 위해 소프트웨어 엔지니어가 할 수 있는 가장 강력한 도구에 속한다. 하지만 단위 테스트를 생각 없이 만들면 시스템에 대한 확신을 키워주는 건 고사하고 시스템을 유지보수하거나 변경하기가 훨씬 어려워 진다.

12.6 핵심 정리

  • 변하지 않는 테스트를 만들기 위해 노력하자
  • 공개 API를 통해 테스트하자
  • 상호작용이 아닌 상태를 테스트하자
  • 테스트를 완전하고 명확하게 만들라
  • 메서드가 아닌 행위를 테스트하라
  • 행위가 부각되게끔 테스트를 구성하라
  • 테스트 이름은 검사하는 행위가 잘 드러나게 지으라
  • 테스트에 로직을 넣지 말라
  • 실패 메시지를 명확하게 작성하라
  • 테스트들이 코드를 공유할 때는 DRY 보다 DAMP를 우선하자
This post is licensed under CC BY 4.0 by the author.

11. 테스트 개요

13. 테스트 대역