좋은 단위 테스트란 무엇인가 — FIRST 원칙에서 계약 테스트까지

실행되어 green이 되는 테스트는 누구나 쓸 수 있지만, 그 모두가 가치를 낳는 것은 아니다. FIRST 원칙과 Vladimir Khorikov의 「네 개의 기둥」으로 단위 테스트의 좋고 나쁨을 정의하고, AAA 패턴과 명명 규칙을 거쳐, 단위 테스트의 바깥——Fowler의 narrow/broad 통합 테스트, Consumer-Driven Contracts와 Pact에 의한 서비스 간 계약 보증——까지, 테스트의 경계를 어떻게 설계할지 따라간다.

testingunit-testingkhorikovmockscontract-testingpact

「좋은 테스트」라는 난제

단위 테스트는 쓰는 것 자체는 어렵지 않다. 실행되어 green이 되는 테스트는 얼마든지 쓸 수 있지만, 그 전부가 가치를 낳는 것은 아니다. 오히려 설계가 나쁜 테스트는 리팩터링 때마다 깨지며 개발 속도를 떨어뜨리는 부채가 된다. 「테스트는 어떠해야 하는가」를 정의하려 한 대표적인 두 개의 틀이 있다. 고전적인 FIRST 원칙과, 더 새로운 Vladimir Khorikov의 「네 개의 기둥」 이다. 이 둘을 축으로 테스트의 독립성·단위·명명이라는 실무적 설계 판단부터, 단위 테스트의 경계를 넘어선 통합 테스트·계약 테스트의 세계까지 따라간다.

FIRST 원칙 — 테스트의 실행 특성

FIRST는 좋은 단위 테스트가 만족해야 할 다섯 가지 성질의 머리글자를 딴 표어로, Robert C. Martin의 저서 『Clean Code』(2008년)에서 Tim Ottinger와 Jeff Langr에 의한 정식화로 소개되었다.

머리글자 원칙 의미
F Fast (빠름) 밀리초 단위로 끝난다. 느린 테스트는 실행 빈도가 낮아져 가치를 잃는다
I Independent (독립) 다른 테스트의 실행 순서·결과에 의존하지 않는다
R Repeatable (반복 가능) 몇 번을 실행해도, 어떤 환경에서도 같은 결과가 나온다
S Self-Validating (자기 검증) 육안으로 로그를 읽지 않아도 pass/fail이 기계적으로 판정된다
T Timely (적시) 구현 직후, 혹은 TDD라면 구현 이전에 작성한다

특히 Independent와 Repeatable은 실행할 때마다 결과가 흔들리는 「플레이키 테스트」의 직접적인 예방책이 된다(자세한 내용은 플레이키 테스트와의 싸움 참조). 테스트 사이에 전역 변수나 DB 상태를 공유하면, 실행 순서에 따라 결과가 달라지는 테스트 스위트가 생긴다.

Khorikov의 「네 개의 기둥」 — 테스트는 무엇을 위해 존재하는가

Robert C. Martin의 FIRST가 「테스트의 실행 특성」에 주안점을 두는 데 비해, Vladimir Khorikov는 저서 『Unit Testing Principles, Practices, and Patterns』(Manning, 2020년)에서 테스트가 장기적으로 가치를 계속 낳기 위한 네 개의 기둥을 제시했다.

  1. 퇴행에 대한 보호 — 버그가 섞여 들어갔을 때, 그 테스트가 확실히 검지할 수 있는가
  2. 리팩터링 내성 — 행동을 바꾸지 않는 내부 구현 변경에 대해, 테스트가 위양성(실제로는 깨지지 않았는데 실패함)을 내지 않는가
  3. 신속한 피드백 — 실행이 빠르고, 개발의 리듬을 방해하지 않는가
  4. 유지보수성 — 테스트 코드 자체가 읽기 쉽고, 작성 비용이 낮은가

Khorikov 논의의 핵심은 이 네 가지를 동시에 최대화할 수는 없다는 지적에 있다. 리팩터링 내성과 유지보수성은 어떤 의미로 이진적인 성질로, 테스트가 구현 세부사항에 결합되어 있으면 깨지고, 결합되어 있지 않으면 깨지지 않는다. 한편 퇴행 보호와 신속한 피드백은 연속적인 트레이드오프가 되기 쉽다. 더 넓은 범위를 검증하면 퇴행 보호는 올라가지만 실행은 느려진다.

Khorikov가 특히 경고하는 것은 목(mock) 의 과도한 사용이 리팩터링 내성을 조용히 파괴한다는 점이다. 「호출되었는가」 「어떤 인수로 호출되었는가」를 검증하는 테스트는 구현 세부사항(어떤 메서드를, 어떤 순서로 호출하는가)에 밀결합되기 쉬워, 내부 구현만 바꿔도 테스트가 붉어진다. Khorikov는 목을 「테스트 대상과 외부의 경계(프로세스 외부 의존)」에만 사용해야 한다고 주장한다(목 자체의 역사는 목과 테스트 더블의 발전 참조).

테스트의 독립성 — 세 가지 수준

FIRST의 Independent와도 겹치지만, 독립성은 다음 세 수준에서 확보해야 한다.

  • 상태의 독립: 어떤 테스트가 다시 쓴 전역 상태·공유 DB 레코드가 다른 테스트의 전제를 깨지 않는다
  • 순서의 독립: 테스트 러너가 어떤 순서로 실행해도 결과가 바뀌지 않는다
  • 환경의 독립: 로컬·CI·개발자마다의 머신에서 같은 결과가 나온다

전형적인 기법으로는, 각 테스트 전후에 상태를 초기화하는(트랜잭션 롤백 등) 것, 테스트마다 새로운 인메모리 의존성을 생성하는 것, 의존성 주입(DI)으로 전역 싱글턴에 대한 의존을 피하는 것 등이 있다.

하나의 테스트, 하나의 행동

좋은 단위 테스트는 하나의 테스트 케이스로 하나의 행동만을 검증한다. 피해야 할 것은 무관한 여러 행동을 하나의 테스트에 몰아넣는 것이다.

// 나쁜 예: 무관한 여러 행동을 한 테스트에 몰아넣음
test("사용자 등록", () => {
  const user = registerUser("[email protected]", "password123");
  expect(user.email).toBe("[email protected]");   // 행동 1
  expect(sendWelcomeEmail).toHaveBeenCalled();      // 행동 2
  expect(auditLog.length).toBe(1);                  // 행동 3
});

// 좋은 예: 행동별로 테스트를 나눈다
test("등록하면 이메일 주소가 저장된다", () => { /* ... */ });
test("등록하면 환영 메일이 발송된다", () => { /* ... */ });

이를 지키면 테스트가 실패했을 때 테스트 이름만으로 「무엇이 깨졌는가」를 알 수 있다. 하나의 테스트에 다수의 어서션이 늘어서 있어 어느 것이 실패했는지 실행 결과를 읽지 않으면 알 수 없는 상태는 「어서션 룰렛」이라 불리며, 피해야 할 안티패턴으로 여겨진다.

AAA 패턴

테스트의 내부 구조를 정리하는 관습으로 널리 쓰이는 것이 AAA 패턴(Arrange-Act-Assert)이다. Bill Wake가 2001년 블로그 글에서 「3A」로 소개했다고 알려져 있으며, 이후 xUnit 계열 프레임워크의 사실상 표준 서술 순서로 자리 잡았다.

def test_withdraw_more_than_balance_raises_error():
    # Arrange: 준비
    account = Account(balance=1000)

    # Act & Assert: 실행과 검증
    with pytest.raises(InsufficientFundsError):
        account.withdraw(2000)

BDD 계열 도구에서는 같은 구조가 Given-When-Then이라는 어휘로 표현된다(BDD의 등장 참조). 이름은 다르지만 「준비·실행·검증」을 분리한다는 발상은 공통적이다.

무엇을 테스트하지 않는가

단위 테스트의 설계는 「무엇을 쓰지 않는가」의 판단도 포함한다. 작성할 가치가 낮은 테스트는 다음과 같은 것들이다.

  • 자명한 코드: 단순한 getter/setter, 로직이 없는 코드
  • 구현 세부사항: private 메서드의 내부 절차, 호출 횟수·순서 그 자체
  • 서드파티·프레임워크의 내부 동작: ORM이나 웹 프레임워크 자체의 정확성
  • 언어 처리계·표준 라이브러리의 정확성: sort()가 올바르게 정렬함을 직접 테스트할 필요는 없다

이들을 테스트해도 퇴행 보호에는 거의 기여하지 않고 유지보수 비용만 늘린다. Khorikov는 이를 테스트의 ROI(투자 대비 효과)라는 말로 설명하며, 가치가 낮은 테스트를 삭제하는 판단도 설계의 일부라고 말한다. 「커버리지 비율을 올리기 위한 테스트」는 이 원칙에서 보면 부채가 될 수 있다(커버리지의 함정은 테스트의 질을 어떻게 측정하는가에서 다룬다).

단위의 바깥으로 — 통합 테스트의 범위 문제

「통합 테스트」라는 말만큼 사람마다 가리키는 범위가 다른 용어도 드물다. 마이크로서비스 아키텍처의 보급과 함께 이 정의의 모호함은 실무상의 문제가 되었다. Martin Fowler는 자신의 bliki 글 “IntegrationTest”에서 범위(scope) 로 정리할 것을 제안했다.

  • Broad integration test(광역): 의존하는 모든 서비스·DB·외부 API를 실물로 띄워 검증한다. 충실도는 높지만 준비가 무겁고 불안정해지기 쉽다
  • Narrow integration test(협역): 외부 서비스와 통신하는 부분만 잘라내어, 외부 쪽은 테스트 더블로 대체해 검증한다. 실행은 빠르고 안정적이지만 실제 동작과의 괴리가 남는다

Fowler는 「broad integration test」는 사실상 system test나 end-to-end test라 부르는 편이 오해가 적다고도 말한다(E2E의 계보는 E2E 테스트의 세대교체 참조).

Sociable / Solitary — 또 하나의 관점

범위와는 별개로, Jay Fields는 저서 『Working Effectively with Unit Tests』에서 의존 대상과 어떻게 관계 맺는지를 축으로 sociable(사교적)solitary(고립적) 이라는 대개념을 제시했다. solitary는 의존 대상을 모두 테스트 더블로 대체하고, sociable은 의존 대상의 실물을 그대로 사용한다. 「narrow/broad」가 주로 프로세스 경계에 주목하는 데 비해, 「sociable/solitary」는 같은 프로세스 안에서 객체 간 협조를 목으로 끊어내는지 여부에 주목하는, 다소 다른 입자도의 축이다.

Testcontainers — Docker로 실물 의존성을 사용한다

「목으로 때우면 충실도가 희생된다」는 문제에 대한 해법으로 퍼진 것이 Testcontainers다. 2015년 Richard North가 개발한 Java용 라이브러리를 기점으로, 이후 Go·.NET·Python·Node.js 등 여러 언어로 이식되었다. 테스트 실행 시 Docker 컨테이너로 실물 의존성(PostgreSQL, Kafka, Redis 등)을 일회용으로 띄우고, 테스트가 끝나면 폐기한다는 발상이다. 인메모리 DB가 가진 「실물과 미묘하게 동작이 다르다」는 문제를 피할 수 있는 반면, 컨테이너 기동 오버헤드로 실행 속도는 떨어진다.

계약 테스트의 등장 — Consumer-Driven Contracts

서비스 A·B·C·D로 의존이 늘어날수록 broad integration test의 유지 비용은 지수적으로 증가한다. 이 문제에 대한 해법으로, Ian Robinson이 2006년 martinfowler.com에 기고한 글 “Consumer-Driven Contracts: A Service Evolution Pattern”에서 제시한 것이 Consumer-Driven Contracts(CDC, 소비자 주도 계약) 다.

CDC의 요점은 다음과 같다.

  1. API의 이용자(consumer) 가 자신이 필요로 하는 요청/응답의 형태를 계약으로 명시적으로 기술한다
  2. 그 계약은 제공자(provider) 측의 CI에서 자동으로 검증된다. 제공자는 계약을 만족하는 한 내부 구현을 자유롭게 변경해도 좋다
  3. 제공자는 여러 소비자로부터 계약을 집약하여, 「이 API를 변경하면 어느 소비자가 깨지는가」를 실제로 결합하지 않고 사전에 파악할 수 있다

이를 통해 의존 전체를 매번 결합해서 움직이지 않아도 「경계별 맞물림」을 서비스 단위로 독립적으로 검증할 수 있게 된다.

Pact — CDC를 구현한 대표적 도구

CDC를 구현한 대표적인 도구가 Pact다. 호주의 부동산 정보 사이트 운영사 REA Group에서의 실무로부터, 컨설팅 회사 DiUS가 개발한 Ruby gem으로 2013년경 태어났으며, 현재는 JVM, JavaScript, .NET, Go, Python 등 여러 언어를 지원하는 계약 테스트 프레임워크로 널리 쓰이고 있다.

전형적인 워크플로는 다음과 같다.

  1. 소비자 측 테스트에서 기대하는 요청/응답 쌍을 기술하고, 그 자리에서 목 서버에 대해 테스트를 실행한다
  2. 그 테스트 실행으로부터 「Pact 파일」이라 불리는 계약 정의(JSON)가 자동 생성된다
  3. Pact 파일은 Pact Broker(계약의 공유·버전 관리 서버)에 발행된다
  4. 제공자 측 CI가 Pact Broker로부터 계약을 가져와, 실제 제공자 코드에 대해 리플레이하여 검증한다(provider verification)

「소비자가 쓴 테스트로부터 계약이 자동 생성된다」는 순서가 Consumer-Driven이라는 이름의 유래이며, 제공자가 일방적으로 정한 명세서가 아니라 실제 이용 실태로부터 계약이 도출된다는 점이 특징이다.

Java/Spring 생태계에서는 다른 접근으로 Spring Cloud Contract가 쓰인다. Pact가 「소비자가 쓴 테스트로부터 계약을 생성」하는 데 비해, Spring Cloud Contract는 「계약(Groovy DSL이나 YAML)을 제공자 측이 정의하고, 거기서 소비자 측 스텁과 제공자 측 검증 테스트 양쪽을 자동 생성한다」는 제공자 주도의 설계를 취한다.

서비스 가상화라는 선택지

계약 테스트를 도입할 정도는 아니거나, 외부 SaaS처럼 상대측 CI에 손이 닿지 않는 경우, 서비스 가상화나 스텁 서버로 대체하는 것도 일반적이다.

도구 특징
WireMock 2011년 Tom Akehurst가 개발한 HTTP 스텁 서버
mountebank Brandon Byars가 개발. HTTP뿐 아니라 TCP·SMTP 등 여러 프로토콜에 대응
Hoverfly HTTP(S) 트래픽의 캡처·재생에 강점을 가짐

계약 테스트와의 차이는, 서비스 가상화가 「정해진 응답을 준비한다」에 그치는 데 비해, 계약 테스트는 「그 정해진 응답이 제공자 측의 실제 동작과 일치하는가」까지 자동 검증한다는 점에 있다.

정리

FIRST가 정하는 「테스트는 어떻게 행동해야 하는가」라는 실행 시의 성질과, Khorikov의 네 개의 기둥이 정하는 「테스트는 무엇을 위해 존재하는가」라는 가치의 성질은 서로 보완적이다. 그리고 단위 테스트의 바깥에서는, Fowler의 narrow/broad라는 범위의 축과, 결합하지 않고 맞물림을 보증하는 계약 테스트라는 도구가, 마이크로서비스화로 인해 지수적으로 증가한 「결합의 조합」 문제에 대한 답이 되고 있다. 최종적으로 물어야 할 것은 「이 테스트는 코드를 바꿀 자유를 얼마나 늘려주는가」라는 한 가지다. 다음 기사에서는 테스트의 「양」을 재는 지표인 커버리지와, 그것을 보완하는 「질」의 지표인 뮤테이션 테스트를 다룬다.

참고문헌

  • Robert C. Martin, 『Clean Code』(2008년, Prentice Hall)
  • Vladimir Khorikov, 『Unit Testing Principles, Practices, and Patterns』(2020년, Manning)
  • Roy Osherove, 『The Art of Unit Testing』제2판 (2013년, Manning)
  • Gerard Meszaros, 『xUnit Test Patterns: Refactoring Test Code』(2007년, Addison-Wesley)
  • Martin Fowler, “IntegrationTest” / “UnitTest” (martinfowler.com/bliki/)
  • Jay Fields, 『Working Effectively with Unit Tests』(2014년, Leanpub)
  • Ian Robinson, “Consumer-Driven Contracts: A Service Evolution Pattern” (martinfowler.com, 2006년)
  • Pact 공식 문서 (pact.io)
  • Testcontainers 공식 문서 (testcontainers.com)
← Back to 소프트웨어 테스트의 계보