한 줄 정의

좋은 단위 테스트란 한 번 작성하면 요구사항이 바뀌지 않는 한 다시 손댈 일이 없는 테스트입니다. 이를 위해 공개 API를 통해 상태를 검증하고(깨지지 않게), 행위 단위로 완전·간결하게 쓰며(명확하게), 코드 공유는 DRY보다 DAMP를 우선합니다.

쉽게 말하면

좋은 단위 테스트는 천장에 달아둔 화재경보기와 같습니다. 한 번 제대로 설치해두면 평소엔 존재조차 잊고 살다가, 진짜 불이 났을 때만 울려서 목숨을 구해줍니다. 설치한 사람이 회사를 떠나도 경보기는 묵묵히 제 일을 합니다.

문제는 토스트만 구워도 삑삑대는 경보기입니다. 처음엔 꼬박꼬박 올라가서 손으로 부채질해 끄지만, 이런 일이 반복되면 결국 사람들은 건전지를 빼버립니다. 그 순간 경보기는 가치가 0이 아니라 음수가 됩니다 — 불이 나도 안 울리는데 다들 “어차피 또 토스트겠지” 하고 무시하니까요.

이 장이 말하는 ‘깨지기 쉬운 테스트(brittle test)‘가 바로 토스트에 울리는 경보기입니다. 관련도 없는 코드를 고쳤는데 빨갛게 떠서, 부랴부랴 끄느라 하루를 날리게 만드는 테스트요. 12장 전체는 결국 “진짜 불(버그)에만 울리고, 울렸을 때 어디서 불이 났는지 한눈에 알 수 있는 경보기를 어떻게 다느냐” 하나의 질문에 대한 답입니다.

왜 중요한가?

단위 테스트는 범위가 좁아(단일 클래스·메서드 수준) 작고 빠르고 결정적이라, 구글은 전체 테스트의 80%를 단위 테스트로 작성하도록 권합니다. 엔지니어 한 명이 매일 수천 개를 돌리는 게 일상입니다. 비중이 이렇게 크기 때문에 단위 테스트의 품질은 곧 개발 생산성과 직결됩니다.

여기서 핵심 지표가 유지보수성(maintainability) 입니다. 유지보수하기 쉬운 테스트란 ‘그냥 작동하는(just work)’ 테스트입니다 — 한 번 작성해두면 신경 쓸 필요 없고, 혹 실패한다면 그건 진짜 버그를 찾았다는 뜻입니다.

유지보수성이 무너지면 어떤 일이 벌어지는지 시나리오로 봅니다.

깨지기 쉽고 불명확한 테스트가 일으키는 일

현우는 제품에 간단한 기능 하나를 추가하려 합니다. 코드 10줄이면 순식간에 끝날 일이었습니다. 그런데 변경사항을 체크인하려 하자 자동 테스트 시스템이 오류 보고서를 한가득 보냅니다. 현우는 실패한 테스트들을 하나씩 고치느라 하루를 다 허비했습니다. 그런데 테스트가 진짜 버그를 찾은 게 아니었습니다. 기존 테스트들이 코드의 내부 구조가 특정 형태일 것이라 가정했기 때문에 깨진 것이고, 무엇을 검증하려는지조차 한눈에 들어오지 않아 고치기도 어려웠습니다. 결국 금방 끝났어야 할 작업 하나 때문에 생산성과 사기가 크게 떨어졌습니다.

이 시나리오에서 테스트는 의도와 정반대 효과를 냈습니다. 품질을 높여주지도 못하면서 생산성을 갉아먹었습니다. 작은 코드베이스라면 테스트 몇 개 손보는 게 부담이 안 되지만, 구글 규모에서는 개개인이 하루 수천 개를 돌리고 대규모 변경 한 번에 수십만 개가 동원됩니다. 이런 규모에서는 아주 적은 비율의 테스트만 깨져도 엄청난 엔지니어링 시간이 증발합니다.

핵심 내용

이 장의 처방은 세 갈래입니다 — (1) 깨지지 않게, (2) 명확하게, (3) 공유는 DAMP로. 차례로 봅니다.

깨지지 않는 테스트 — 변경에 둔감하게 만들기

이상적인 테스트는 불변(unchanging) 입니다. 한 번 작성하면 대상 시스템의 요구사항이 바뀌지 않는 한 절대 수정할 일이 없어야 합니다. 이를 점검하려면 “엔지니어가 제품 코드를 바꾸는 유형마다 테스트가 어떻게 반응해야 하는가”를 따져야 합니다.

변경 유형 네 가지 — 셋은 테스트를 건드리면 안 된다

변경 유형테스트가 깨져야 하나?깨졌다면 의미
순수 리팩터링 (외부 인터페이스 그대로, 내부만 개선)❌ 아니오행위가 실제로 바뀌었거나, 테스트가 내부 구현에 지나치게 의존하고 있었음
새로운 기능 추가❌ 아니오 (새 테스트만 추가)새 기능이 기존 행위에 의도치 않은 영향을 줬거나, 테스트 자체에 문제가 있음
버그 수정❌ 아니오 (누락됐던 테스트를 추가)버그란 원래 있었어야 할 테스트가 빠졌다는 신호. 기존 테스트는 그대로여야 함
행위 변경 (시스템의 약속을 의도적으로 변경)✅ 예유일하게 테스트 수정이 정당한 경우. 비용이 가장 큼

핵심은 리팩터링·새 기능·버그 수정에서는 기존 테스트를 손볼 일이 없어야 한다는 것입니다. 이 원칙이 지켜져야 시스템을 확장할 때 기존 테스트를 일일이 손보지 않고 확장한 부분과 관련된 소수의 테스트만 새로 작성하면 됩니다. 대규모 시스템을 다룰 수 있느냐가 여기서 갈립니다.

행위 변경만 테스트 수정을 요구하는데, 이는 시스템이 사용자와 맺은 약속을 의도적으로 바꾸는 파괴적 변경입니다. 이때의 테스트 갱신 비용은 모든 사용자의 코드를 갱신하는 비용보다 대체로 저렴하므로, 저수준 라이브러리일수록 애초에 행위를 바꿀 일이 없도록 설계에 엄청난 노력을 기울입니다.

공개 API로 테스트하자 — 정문 우선 원칙

깨지지 않는 테스트를 만드는 가장 중요한 묘책은 테스트도 시스템을 다른 사용자 코드와 똑같은 방식으로 호출하기입니다. 내부 구현이 아니라 공개 API를 호출하면, 정의상 테스트가 실패하면 실제 사용자도 똑같은 문제를 겪습니다. 이를 정문 우선 원칙(use the front door first principle) 이라 부릅니다.

은행 거래를 검증하고 DB에 저장하는 시스템을 예로 봅니다.

// 코드 12-1: 은행 거래 API
public void processTransaction(Transaction transaction) {
  if (isValid(transaction)) {
    saveToDatabase(transaction);
  }
}
 
private boolean isValid(Transaction t) {
  return t.getAmount() < t.getSender().getBalance();
}
 
private void saveToDatabase(Transaction t) {
  String s = t.getSender() + "," + t.getRecipient() + "," + t.getAmount();
  database.put(t.getId(), s);
}
 
public void setAccountBalance(String accountName, int balance) { /* DB에 직접 기록 */ }
public void getAccountBalance(String accountName) { /* DB에서 잔고 조회 */ }

이 코드를 테스트하라고 하면 private 제한자를 떼고 내부 로직을 직접 검증하고 싶은 유혹에 빠집니다.

// 코드 12-2: 구현을 직접 검증하는 경솔한 테스트 (나쁨)
@Test public void emptyAccountShouldNotBeValid() {
  assertThat(processor.isValid(newTransaction().setSender(EMPTY_ACCOUNT))).isFalse();
}
@Test public void shouldSaveSerializedData() {
  processor.saveToDatabase(newTransaction().setId(123).setSender("me").setRecipient("you").setAmount(100));
  assertThat(database.get(123)).isEqualTo("me,you,100");
}

이 테스트는 시스템을 실제 사용자와 전혀 다르게 씁니다. 비공개 메서드 isValid·saveToDatabase를 직접 부르고, 직렬화 포맷 "me,you,100"까지 못박았습니다. 메서드 이름을 바꾸거나 직렬화 포맷을 손대는 등 리팩터링만 했다 하면 거의 매번 실패합니다. 사용자에게는 보이지도 않는 변경인데도요.

같은 커버리지를 공개 API만으로도 달성할 수 있습니다.

// 코드 12-3: 공개 API로 테스트 (좋음)
@Test public void shouldTransferFunds() {
  processor.setAccountBalance("me", 150);
  processor.setAccountBalance("you", 20);
  processor.processTransaction(newTransaction().setSender("me").setRecipient("you").setAmount(100));
  assertThat(processor.getAccountBalance("me")).isEqualTo(50);
  assertThat(processor.getAccountBalance("you")).isEqualTo(120);
}
@Test public void shouldNotPerformInvalidTransactions() {
  processor.setAccountBalance("me", 50);
  processor.setAccountBalance("you", 20);
  processor.processTransaction(newTransaction().setSender("me").setRecipient("you").setAmount(100));
  assertThat(processor.getAccountBalance("me")).isEqualTo(50);
  assertThat(processor.getAccountBalance("you")).isEqualTo(20);
}

명시된 규약만 검증하므로 내부는 얼마든지 리팩터링해도 됩니다. 보너스로 사용자에게 유용한 예제 코드·문서자료 역할까지 합니다.

어디까지가 '공개 API'인가 — '단위'의 정의

공개 API로 테스트하라는 원칙은 곧 ‘단위’가 무엇이냐는 질문으로 이어집니다. 단위는 개별 함수처럼 작을 수도, 관련된 여러 패키지·모듈의 묶음처럼 넓을 수도 있습니다. 이 맥락에서 공개 API란 그 단위의 소유자가 서드파티에 노출한 API를 뜻하며, 프로그래밍 언어의 가시성(public/private)과는 다를 수 있습니다. 자바의 public 클래스가 같은 단위 안 다른 패키지에서 쓸 용도일 뿐 외부 노출 의도는 아닐 수 있으니까요. 과학적 정답은 없지만 쓸 만한 경험법칙이 있습니다.

  • 도우미 클래스(소수의 다른 클래스를 보조하는 용도)는 독립 단위로 보지 말고, 이들이 보조하는 클래스를 통해 우회적으로 테스트합니다.
  • 누구나 접근하도록 설계된 패키지·클래스는 거의 예외 없이 직접 테스트해야 하는 단위입니다.
  • 소유자만 접근하지만 다방면으로 유용한 지원 라이브러리도 직접 테스트하는 단위로 봅니다(라이브러리 자체용 테스트와 사용처 테스트가 다소 중복되지만 유익한 중복입니다).

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

테스트가 내부 구현에 의존하는 또 다른 대표 유형은 무엇을 검증하느냐에서 갈립니다.

  • 상태 테스트(state test): 메서드 호출 후 시스템 자체를 관찰합니다. (“결과가 무엇(what)인가”)
  • 상호작용 테스트(interaction test): 호출을 처리하는 과정에서 시스템이 다른 모듈을 어떻게 호출하는지 확인합니다. (“어떻게(how) 결과에 도달했는가”)
// 코드 12-4: 상호작용 테스트 (깨지기 쉽다)
@Test public void shouldWriteToDatabase() {
  accounts.createUser("foobar");
  verify(database).put("foobar"); // DB의 put()이 호출됐는지 확인
}

이 테스트는 설계 의도와 어긋나는 두 가지 시나리오에서 잘못 판단합니다.

  • 시스템에 버그가 있어 레코드가 쓰인 직후 삭제돼도 테스트는 성공합니다 (실패해야 하는 상황인데).
  • 같은 기능을 다른 API로 수행하도록 리팩터링하면 테스트는 실패합니다 (성공해야 하는 상황인데).
// 코드 12-5: 상태를 확인하는 테스트 (좋음)
@Test public void shouldCreateUsers() {
  accounts.createUser("foobar");
  assertThat(accounts.getUser("foobar")).isNotNull();
}

상태 테스트는 “기능 호출 후 시스템이 어떤 상태에 놓이는가”라는, 우리가 진짜 관심 있는 것을 표현합니다. put을 호출했든 다른 방법을 썼든 사용자가 생기기만 하면 통과합니다.

상호작용 테스트의 주범 — 모의 객체 과용

잠재적으로 문제가 되는 상호작용 테스트가 만들어지는 가장 큰 원인은 모의 객체 프레임워크(mocking framework)에 지나치게 의존하기 때문입니다. 모의 객체는 테스트 대역을 쉽게 만들고 모든 호출을 기록·검증하게 해주는데, 이 편리함이 깨지기 쉬운 상호작용 테스트를 양산하도록 유혹합니다. 진짜 객체가 빠르고 결정적이라면 모의 객체를 지양하고 진짜 객체를 써야 합니다.

명확한 테스트 — 실패했을 때 곧바로 이해되는가

깨지기 쉬운 요소를 완전히 제거해도 언젠가는 테스트가 실패합니다. 실패는 좋은 것입니다 — 단위 테스트의 존재 가치를 증명하는 신호니까요. 테스트가 실패하는 이유는 크게 둘입니다.

  1. 대상 시스템에 문제가 있다 → 버그를 고치라는 경고.
  2. 테스트 자체에 결함이 있다 → 깨지기 쉬운 테스트라는 뜻.

엔지니어는 실패 시 가장 먼저 이 둘 중 어디에 속하는지 가려낸 후 실제 문제를 조사합니다. 이 일을 얼마나 빨리 마치느냐가 테스트의 명확성(clarity) 에 달렸습니다. 명확한 테스트란 존재 이유와 실패 원인을 엔지니어가 곧바로 알아차릴 수 있는 테스트입니다.

불명확한 테스트의 최후

불명확한 테스트는 목적을 결코 알아내지 못할 가능성이 큽니다. 제거해도 커버리지만 미세하게 낮아질 뿐 아무것도 달라지지 않으니까요. 최악의 경우 해법을 못 찾은 엔지니어가 이 테스트를 통째로 걷어내 버립니다. 그러면 그 테스트는 탄생해서 생을 마칠 때까지 아무런 가치도 만들어내지 못한 셈이 됩니다. 화재경보기의 건전지를 빼버리는 것과 똑같습니다.

완전하고 간결하게 만들자

  • 완전한(complete) 테스트: 결과에 도달하기까지의 논리를 이해하는 데 필요한 모든 정보를 본문에 담습니다.
  • 간결한(concise) 테스트: 그와 동시에 관련 없는 정보는 담지 않습니다.
// 코드 12-6: 불완전하고 산만한 테스트 (나쁨)
@Test public void shouldPerformAddition() {
  Calculator calculator = new Calculator(new RoundingStrategy(),
      "unused", ENABLE_COSINE_FEATURE, 0.01, calculusEngine, false);
  int result = calculator.calculate(newTestCalculation());
  assertThat(result).isEqualTo(5); // 이 숫자는 뭘 뜻하지?
}

생성자는 관련 없는 정보("unused", ENABLE_COSINE_FEATURE 등)를 잔뜩 받고 있어 산만하고(불간결), 정작 중요한 입력값은 도우미 메서드 newTestCalculation() 안에 숨어 있어 5가 어디서 나왔는지 알 수 없습니다(불완전).

// 코드 12-7: 완전하고 간결한 테스트 (좋음)
@Test public void shouldPerformAddition() {
  Calculator calculator = new Calculator();
  int result = calculator.calculate(newCalculation(2, Operation.PLUS, 3));
  assertThat(result).isEqualTo(5);
}

Quote

테스트 본문에는 테스트를 이해하는 데 필요한 정보를 모두 담아야 하며, 그와 동시에 눈을 어지럽히거나 관련 없는 정보는 담지 않아야 합니다.

여기서 테스트를 더 명확하게 만들 수 있다면 DRY(Don’t Repeat Yourself) 원칙을 거스르는 게 나을 때도 많습니다. 이 통찰이 12.4절 DAMP로 이어집니다.

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

많은 엔지니어가 본능적으로 테스트 구조를 대상 코드 구조와 일치시킵니다 — 메서드 하나에 테스트 메서드 하나. 처음엔 편하지만, 대상 메서드가 복잡해질수록 테스트도 함께 복잡해져 실패해도 원인을 파악하기 어려워집니다.

// 코드 12-8: 거래 처리 코드 (최대 두 가지 메시지를 출력)
public void displayTransactionResults(User user, Transaction transaction) {
  ui.showMessage(transaction.getItemName() + "을(를) 구입하셨습니다.");
  if (user.getBalance() < LOW_BALANCE_THRESHOLD) {
    ui.showMessage("경고: 잔고가 부족합니다!");
  }
}
// 코드 12-9: 메서드 중심 테스트 (나쁨) — 한 테스트에서 두 메시지를 모두 검증
@Test public void testDisplayTransactionResults() {
  transactionProcessor.displayTransactionResults(
      newUserWithBalance(LOW_BALANCE_THRESHOLD.plus(dollars(2))),
      new Transaction("물품", dollars(3)));
  assertThat(ui.getText()).contains("물품을(를) 구입하셨습니다.");
  assertThat(ui.getText()).contains("잔고가 부족합니다!");
}

대신 행위(behavior) 별로 작성합니다. 행위란 특정 상태에서 특정 입력을 받았을 때 시스템이 보장하는 반응이며, 흔히 given/when/then으로 표현합니다 (‘(given) 잔고가 빈 상태에서, (when) 돈을 인출하려 하면, (then) 거래를 거부한다’). 메서드와 행위는 다대다 관계입니다.

// 코드 12-10: 행위 주도 테스트 (좋음) — 행위마다 테스트 하나
@Test public void displayTransactionResults_showsItemName() {
  transactionProcessor.displayTransactionResults(new User(), new Transaction("물품"));
  assertThat(ui.getText()).contains("물품을(를) 구입하셨습니다.");
}
@Test public void displayTransactionResults_showsLowBalanceWarning() {
  transactionProcessor.displayTransactionResults(
      newUserWithBalance(LOW_BALANCE_THRESHOLD.plus(dollars(2))),
      new Transaction("물품", dollars(3)));
  assertThat(ui.getText()).contains("잔고가 부족합니다!");
}

테스트가 늘었지만 행위 주도 테스트가 메서드 중심보다 명확한 이유는 셋입니다 — ① 자연어에 가까워 분석 없이 이해되고, ② 각각 좁은 범위를 검사해 원인과 결과가 분명하며, ③ 짧고 서술적이라 무엇을 이미 검사했는지 쉽게 확인됩니다.

테스트 구조는 행위가 부각되도록

모든 행위는 given/when/then 세 요소로 구성됩니다(‘arrange/act/assert’라고도 합니다). 이 구조가 명확히 드러나면 최고로 명백한 테스트입니다(Cucumber·Spock 같은 프레임워크는 given/when/then을 직접 지원합니다). 다른 언어에서는 빈 줄과 주석으로 표현합니다.

// 코드 12-11: 잘 구조화된 테스트
@Test public void transferFundsShouldMoveMoneyBetweenAccounts() {
  // Given: 두 개의 계좌. 각각의 잔고는 $150와 $20
  Account account1 = newAccountWithBalance(usd(150));
  Account account2 = newAccountWithBalance(usd(20));
 
  // When: 첫 번째 계좌에서 두 번째 계좌로 $100 이체
  bank.transferFunds(account1, account2, usd(100));
 
  // Then: 각 계좌 잔고에 이체 결과가 반영됨
  assertThat(account1.getBalance()).isEqualTo(usd(50));
  assertThat(account2.getBalance()).isEqualTo(usd(120));
}

when 블록 중간의 단정문을 조심하라

이 패턴을 무너뜨리는 가장 큰 원흉은 시스템을 호출하는 코드 사이사이에 끼어드는 단정문입니다. when 블록 중간에 단정문이 들어가면 그것이 중간 과정인지 최종 예상 결과인지 구분하기 어려워집니다. 여러 단계를 단계별로 검증하고 싶을 때는 when과 then 블록을 교대로 배치하고, 긴 블록은 ‘and’로 연결하면 잘 읽힙니다.

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

메서드 중심 테스트는 대상 메서드 이름을 따 짓습니다(updateBalancetestUpdateBalance). 반면 행위 주도 테스트는 이름이 자유로워 더 의미 있는 정보를 담습니다. 테스트 이름은 매우 중요합니다 — 실패 보고서에 이름만 덩그러니 표시되는 경우가 많아 문제를 설명할 유일한 단서가 이름일 수 있기 때문입니다.

이름에는 검사하려는 행위를 요약해 시스템의 동작과 예상 결과를 모두 담아야 합니다. 자스민(jasmine) 같은 프레임워크는 describe/it 중첩으로 이름을 문장처럼 만듭니다(코드 12-13). 자바처럼 중첩이 어렵다면 모든 정보를 메서드 이름에 녹입니다.

// 코드 12-14: 메서드 이름에 전부 녹이는 예
multiplyingTwoPositiveNumbersShouldReturnAPositiveNumber
multiply_postiveAndNegative_returnsNegative
divide_byZero_throwsException

제품 코드라면 추천하지 않을 상세한 이름이지만, 이 메서드들은 호출되는 게 아니라 보고서를 통해 사람에게 읽힙니다. 사람이 읽을 이름이므로 상세해도 괜찮습니다. 좋은 전략이 없으면 should로 시작하고 대상 클래스 이름을 곁들여 문장으로 만들어보세요(BankAccount + shouldNotAllowWithdrawalsWhenBalanceIsEmpty = “BankAccount should not allow withdrawals when balance is empty”). 이름에 and가 들어간다면 여러 행위를 검사하고 있을 가능성이 큽니다 — 그렇다면 테스트를 쪼개야 합니다.

테스트에 논리를 넣지 말자

제품 코드는 다양한 입력을 처리하도록 일반화해야 하므로 복잡합니다. 테스트는 몇 가지 제한된 입력만 다루므로 그런 사치가 필요 없습니다. 테스트를 검증하는 테스트가 필요할 것 같은 느낌이 들면 무언가 잘못된 것입니다.

복잡성은 대체로 논리(logic) — 연산자·반복문·조건문 같은 명령형 요소 — 형태로 나타납니다. 논리가 조금만 들어가도 추론이 어려워지고, 그 안에 버그가 숨습니다.

// 코드 12-15: 논리가 버그를 감추는 예
@Test public void shouldNavigateToAlbumsPage() {
  String baseUrl = "http://photos.google.com/";
  Navigator nav = new Navigator(baseUrl);
  nav.goToAlbumPage();
  assertThat(nav.getCurrentUrl()).isEqualTo(baseUrl + "/albums");
}

여기엔 논리가 많지 않습니다. 단 한 번! 마지막 줄에서 문자열을 연결할 뿐입니다. 하지만 이 작은 논리를 없애면 감춰져 있던 버그가 드러납니다.

// 코드 12-16: 논리를 제거하니 버그가 드러남
@Test public void shouldNavigateToPhotosPage() {
  Navigator nav = new Navigator("http://photos.google.com/");
  nav.goToPhotosPage();
  assertThat(nav.getCurrentUrl())
      .isEqualTo("http://photos.google.com//albums"); // 이런! 슬래시가 두 번
}

전체 문자열을 직접 적어주자 URL에 슬래시(/)를 두 번 썼음이 바로 드러났습니다. baseUrl + "/albums"처럼 제품 코드와 똑같은 논리로 기댓값을 만들면, 제품 코드에 같은 버그가 있어도 테스트가 그걸 잡지 못합니다. 교훈은 분명합니다 — 테스트에서는 스마트한 로직보다 직설적인 코드를 고집하고, 더 서술적인 테스트를 위한 약간의 중복은 허용합니다.

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

실전에서는 실패 보고서나 로그에 찍힌 메시지 한 줄만으로 문제 원인을 찾아야 할 때가 많습니다. 잘 작성된 실패 메시지는 ‘원하는 결과’, ‘실제 결과’, ‘건네진 매개변수의 값’ 을 명확히 알려줘야 합니다.

Test failed: account is closed          ← 나쁨 (닫혀서 실패? 닫히길 기대했는데 아니라서 실패?)

Expected an account in state CLOSED, but got account:
    <{name: "my-account", state: "OPEN"}>   ← 좋음 (기대/실제/맥락 모두 명시)

좋은 단정문 라이브러리를 쓰면 이런 메시지를 쉽게 얻습니다.

// 코드 12-17: JUnit vs Truth
Set<String> colors = ImmutableSet.of("red", "green", "blue");
assertTrue(colors.contains("orange"));   // JUnit → "expected <true> but was <false>" (맥락 없음)
assertThat(colors).contains("orange");   // Truth → "AssertionError: <[red, green, blue]> should have contained <orange>"

전통적인 assertTrue는 Boolean 하나만 받아 맥락이 빈약합니다. 구글의 Truth 같은 라이브러리는 단정 대상을 구분해 받으므로 훨씬 유용한 메시지를 냅니다. 라이브러리 지원이 없는 언어라면 수동으로라도 중요 정보를 메시지에 넣어야 합니다(Go의 t.Errorf("Add(2, 3) = %v, want %v", result, 5)처럼).

코드 공유는 DRY가 아니라 DAMP

테스트를 명확하고 깨지지 않게 해주는 마지막 요인은 코드 공유(code sharing) 입니다. 대부분의 소프트웨어는 DRY(Don’t Repeat Yourself) 를 숭배합니다 — 개념을 한 곳에서만 구현해 중복을 최소화하면 유지보수가 쉽다는 원칙입니다. 기능 변경 시 한 곳만 고치면 되니까요.

하지만 테스트 코드에서는 사정이 다릅니다. 제품 코드는 복잡해져도 동작을 보장하는 테스트 스위트가 있지만, 테스트를 보호해줄 테스트는 존재하지 않습니다. 테스트가 복잡해지면 버그가 생길 위험이 커지고, 그렇다고 ‘테스트를 검증할 테스트’가 필요해진다면 무언가 잘못된 것입니다. 게다가 DRY로 로직을 한곳에 모으면 참조에 참조를 따라가야 해서 명확성이 떨어집니다.

그래서 테스트는 DAMP(Descriptive And Meaningful Phrase) — ‘서술적이고 의미 있는 문구’ — 를 우선합니다. 단순하고 명료하게 만들 수 있다면 다소의 중복은 괜찮습니다.

// 코드 12-19: DRY에 집착한 테스트 (나쁨)
@Test public void shouldAllowMultipleUsers() {
  List<User> users = createUsers(false, false);   // false, false가 뭐지?
  Forum forum = createForumAndRegisterUsers(users);
  validateForumAndUsers(forum, users);            // 무엇을 검증하는지 본문엔 없음
}
// createUsers / createForumAndRegisterUsers / validateForumAndUsers 도우미 안에
// 반복문·조건문·try-catch 로직이 잔뜩 숨어 있어 무슨 일을 하는지 한눈에 안 들어옴
// 코드 12-20: 테스트는 DAMP를 따라야 한다 (좋음)
@Test public void shouldAllowMultipleUsers() {
  User user1 = newUser().setState(State.NORMAL).build();
  User user2 = newUser().setState(State.NORMAL).build();
  Forum forum = new Forum();
  forum.register(user1);
  forum.register(user2);
  assertThat(forum.hasRegisteredUser(user1)).isTrue();
  assertThat(forum.hasRegisteredUser(user2)).isTrue();
}
@Test public void shouldNotRegisterBannedUsers() {
  User user = newUser().setState(State.BANNED).build();
  Forum forum = new Forum();
  try {
    forum.register(user);
  } catch (BannedUserException ignored) {}
  assertThat(forum.hasRegisteredUser(user)).isFalse();
}

중복된 코드도 보이고 본문도 길어졌지만, 각 테스트가 훨씬 의미 있어졌고 본문만 봐도 전체를 이해할 수 있습니다.

DAMP는 DRY를 대체하지 않는다

DAMP는 DRY를 대체하는 게 아니라 보완합니다. 도우미 메서드와 테스트 인프라는 여전히 테스트를 더 명확하게 만드는 데 도움을 줄 수 있습니다 — 본문을 간결하게 해주고, 검사하려는 행위와 무관하게 반복되는 세세한 단계를 추상화할 수 있습니다. 핵심은 테스트에서의 리팩터링은 반복을 줄이는 게 아니라 더 서술적이고 의미 있게 하는 방향으로 이루어져야 한다는 점입니다.

이어서 여러 테스트가 코드를 공유하는 일반적 패턴들을 살펴봅니다.

공유 값 — 도우미 메서드로 생성하라

여러 테스트가 공유할 값을 상수로 정의해두는 방식은 간결하지만, 스위트가 커지면 왜 그 값을 골랐는지 알기 어렵습니다.

// 코드 12-21: 이름이 모호한 공유 값 (나쁨)
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();
// ... 수백 줄 아래 테스트에서 ACCOUNT_1이 적합한지 확인하려면 위로 스크롤해야 함

CLOSED_ACCOUNT처럼 이름을 서술적으로 지으면 약간 도움이 되지만, 세부 정보를 알려면 여전히 스크롤해야 합니다. 더 나은 방법은 도우미 메서드로 데이터를 구성하되, 필요한 값만 명시하고 나머지는 적절한 기본값을 채워주는 것입니다.

# 코드 12-22: 도우미 메서드로 값 공유 — 필요한 매개변수만 명시
def newContact(firstName="Grace", lastName="Hopper", phoneNumber="555-123-4567"):
  return Contact(firstName, lastName, phoneNumber)
 
def test_fullNameShouldCombineFirstAndLastNames(self):
  contact = newContact(firstName="에이다", lastName="러브레이스")  # 필요한 값만
  self.assertEqual(contact.fullName(), "에이다 러브레이스")

이름 있는 매개변수(named parameter)를 지원하는 언어라면 쉽고, 자바처럼 지원하지 않으면 가변 빌더 객체를 반환해 흉내 냅니다. 이렇게 하면 불필요한 정보로 오염되거나 다른 테스트와 충돌할 걱정 없이 정확히 필요한 값만 생성합니다.

기본값을 무작위로 채우면 더 좋다

값을 명시하지 않은 필드에 채울 기본값에 약간의 무작위성을 주면, 실수로 서로 다른 두 인스턴스가 동치라고 판단하는 일을 막아주고 엔지니어가 기본값을 가정하고 하드코딩하기가 더 어려워집니다.

공유 셋업 — 특정 값에 의존하면 위험하다

@Before 같은 셋업 메서드는 지루하고 검증과 무관한 초기화 로직(대상 객체·협력 객체 생성)을 깔끔하게 정리해주는 유용한 도구입니다. 하지만 셋업이 정한 특정 값에 테스트가 의존하기 시작하면 악몽이 시작됩니다.

// 코드 12-23: 셋업이 정한 값에 의존 (나쁨)
@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("도널드 커누스");  // ...여기서 쓰임. 어디서 왔는지 본문엔 없음
}

"도널드 커누스"가 어디서 왔는지 읽는 사람이 셋업까지 찾아가야 하므로 불완전합니다. 특정 값을 요구하는 테스트라면 그 값을 본문에서 직접 기술하거나 셋업 기본값을 덮어써야 합니다.

// 코드 12-24: 셋업 값을 본문에서 덮어씀 (좋음)
@Test public void shouldReturnNameFromService() {
  nameService.set("user1", "마거릿 해밀턴");      // 이 테스트가 쓰는 값을 직접 명시
  UserDetails user = userStore.get("user1");
  assertThat(user.getName()).isEqualTo("마거릿 해밀턴");
}

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

모든 테스트가 마지막에 호출하는 도우미 메서드(특히 단정 로직 전체를 옮긴 것)는 매우 위험합니다. 테스트의 의도를 추론하기 어려워지고, 버그 하나에 여러 테스트가 동시다발로 실패해 살펴볼 범위를 좁히기 어렵습니다.

하지만 하나의 목적에 집중하는 검증 메서드(validation method) 는 여전히 유용합니다. 여러 조건을 확인하는 게 아니라 입력에 대한 단 하나의 ‘개념적 사실’ 만 검증하는 것입니다. 개념은 단순하지만 그것을 검사하는 로직이 복잡할 때(반복문·조건문이 필요할 때) 특히 빛납니다.

// 코드 12-25: 개념적으로 단순한 테스트 — "이 사용자가 이 계정에 접근 가능한가"
private void assertUserHasAccessToAccount(User user, Account account) {
  for (long userId : account.getUsersWithAccess()) {
    if (user.getId() == userId) {
      return;
    }
  }
  fail(user.getName() + " cannot access " + account.getName());
}

테스트 인프라 정의하기

지금까지의 기법은 하나의 클래스·스위트 안의 코드 공유였습니다. 다른 테스트 스위트와도 코드를 공유하면 유용할 때가 있는데, 구글은 이를 테스트 인프라(test infrastructure) 라 부릅니다. 주로 통합 테스트나 종단간 테스트에서 빛을 발합니다.

테스트 인프라는 제품 코드처럼 다뤄라

테스트 인프라는 단일 스위트 안의 코드 공유보다 훨씬 신중하게 접근해야 합니다. 많은 곳에서 호출되는 만큼 의존하는 코드가 많고, 동작이 달라지면 다른 코드들이 깨지기 때문에 변경도 어렵습니다. 일반 테스트 코드보다 제품 코드와 비슷한 특성을 보이므로, 테스트 인프라는 마땅히 자신을 검사해줄 자체 테스트들을 갖추고 있어야 합니다.

대부분의 테스트 인프라는 JUnit 같은 잘 알려진 서드파티 라이브러리 형태로 제공됩니다. 이런 라이브러리는 엄청나게 많으므로 가능하면 조기에 전사 표준 인프라를 정하는 게 유리합니다. 구글은 수년 전 모키토(Mockito)를 표준 모의 객체 프레임워크로 정하고 새 자바 테스트는 반드시 이것만 쓰도록 했습니다 — 당시엔 아쉬워한 사람도 있었지만, 결과적으로 테스트들을 더 이해하기 쉽고 잘 어우러지게 만든 좋은 조치였습니다.

비교 / 트레이드오프

상태 테스트 vs 상호작용 테스트

getAccountBalance() == 50(상태)이냐, verify(database).put(...)(상호작용)이냐의 차이입니다. 상호작용 테스트가 비공개 메서드 테스트와 같은 이유로 더 깨지기 쉽습니다.

관점상태 테스트상호작용 테스트
검증 대상호출 후 시스템의 상태 (결과 = what)다른 모듈을 어떻게 호출했는지 (과정 = how)
리팩터링 내성강함 (다른 API로 같은 결과 내도 통과)약함 (구현 방식 바꾸면 실패)
버그 탐지결과가 틀리면 잡음호출은 맞지만 결과가 틀린 버그를 놓침
주된 도구진짜 객체모의 객체 프레임워크
권장도기본으로 선호진짜 객체가 느리거나 비결정적일 때만
DRY vs DAMP
관점DRY (제품 코드의 기본)DAMP (테스트 코드의 기본)
우선순위중복 최소화 → 변경 한 곳에 집중서술성·명확성 → 본문만 봐도 이해
중복 허용지양명확성을 높인다면 허용
안전망코드를 보호하는 테스트가 있음테스트를 보호하는 테스트는 없음
관계DRY를 대체하지 않고 보완 (도우미·인프라는 여전히 유효)

내 생각

  • ‘리팩터링했는데 테스트가 깨진다’가 곧 설계 냄새라는 진단이 강력합니다. 깨졌다면 행위가 실제로 바뀌었거나, 테스트가 내부 구현을 훔쳐보고 있었거나 둘 중 하나입니다. 비공개 메서드를 @VisibleForTesting으로 열어 직접 테스트하던 습관을 돌아보게 됩니다.

  • 상호작용 테스트는 Mockito verify() 남용과 정확히 겹칩니다. 레포지토리 save()가 불렸는지를 verify하는 테스트는 JPA로 영속화 방식을 바꾸는 순간 깨집니다. 인메모리 fake로 바꿔 “저장 후 조회되는가”라는 상태를 검증하는 편이 리팩터링에 훨씬 강합니다.

  • DAMP는 테스트 코드 리뷰의 실전 기준이 됩니다. “여기 중복 있으니 도우미로 빼자”는 제품 코드 본능을 테스트에 들이미는 순간 코드 12-19가 됩니다. 테스트 PR에서는 “본문만 읽고 의도가 이해되는가”를 중복보다 우선해 봅니다.

  • 테스트 이름에 and가 있으면 쪼개라는 휴리스틱이 즉시 써먹기 좋습니다. 행위 하나당 테스트 하나(given/when/then 하나씩)라는 규칙과 짝을 이뤄, 실패 보고서에서 이름만 보고 원인을 좁히는 능력을 키워줍니다.

관련 개념

  • Ch11 테스트 개요 — 크기·범위 분류와 테스트 피라미드. 이 장의 ‘단위 테스트’는 그 80%를 차지하는 좁은 범위·작은 크기 테스트입니다
  • Ch09 코드 리뷰 — 테스트가 리뷰를 단순화한다는 흐름과, 테스트 코드 자체를 리뷰할 때의 DAMP 기준
  • Ch10 문서자료 — 공개 API 테스트가 사용자에게 ‘실행 가능한 예제 문서’ 역할을 한다는 연결