본문으로 건너뛰기

Test

1. 테스트 지침​

  • 이 문서는 LLM이 테스트 코드를 작성/리뷰할 때 참고할 지침서다.
  • Vladimir Khorikov의 『단위 테스트』(원제: Unit Testing: Principles, Practices, and Patterns)를 바탕으로, docs/Test/Unit-Testing에 정리된 내용을 LLM이 코드를 생성할 때 바로 적용할 수 있는 규칙 형태로 재구성했다.
  • 이 지침의 핵심 결론 하나만 기억한다면: 테스트는 구현 방법이 아니라 최종 결과(식별할 수 있는 동작)를 검증해야 한다.

2. 테스트를 작성하는 이유​

  • 테스트의 목표는 커버리지 수치가 아니라 프로젝트가 장기적으로 지속 가능한 속도로 성장하도록 돕는 것이다.
  • 코드 커버리지는 참고 지표일 뿐 품질을 보장하지 않는다. 100% 커버리지를 채우려고 무가치한 테스트를 양산하지 않는다.
  • 시스템의 가장 중요한 부분(비즈니스 로직, 도메인 모델)에 테스트를 집중하고, 단순한 보일러플레이트 코드(단순 getter/setter, 프레임워크 설정)에는 테스트를 강요하지 않는다.
  • 가치가 유지비를 넘어서는 테스트만 남긴다. 실행되지 않거나 매번 깨지기만 하는 테스트는 안전망이 아니라 짐이다.

3. 좋은 테스트를 가르는 4가지 기준​

새로 작성하거나 리뷰하는 테스트는 아래 4가지 기준으로 평가한다.

기준의미
회귀 방지실제로 버그가 있을 때 테스트가 이를 잡아내는가
리팩터링 내성동작을 바꾸지 않는 리팩터링에도 테스트가 통과하는가 (거짓 양성이 없는가)
빠른 피드백테스트가 얼마나 빨리 실행되는가
유지 보수성테스트를 이해하고 실행하기 얼마나 쉬운가
  • 이 네 가지는 서로 상충한다. 특히 회귀 방지·리팩터링 내성·빠른 피드백은 하나를 100% 얻으려면 다른 하나를 희생해야 하는 관계다.
  • 네 가지 중 리팩터링 내성만은 절대 희생하지 않는다. 리팩터링 내성은 0 아니면 1이지 중간이 없기 때문이다. 리팩터링할 때마다 애먼 테스트가 깨진다면, 그 테스트는 실질적인 안전망이 아니라 유지비만 발생시키는 애물단지다.
  • 리팩터링 내성이 없는 테스트(거짓 양성)가 반복되면 개발자는 실패를 무시하는 습관이 생기고, 결국 진짜 회귀도 못 잡아낸다.
// 거짓 양성을 유발하는 예: 구현 세부 사항(내부에서 사용하는 하위 컴포넌트 목록)에 결합됨
@Test
fun `MessageRenderer는 올바른 서브 렌더러를 사용한다`() {
val sut = MessageRenderer()
val renderers = sut.subRenderers // 내부 구현을 노출/검증

assertThat(renderers).hasSize(3)
assertThat(renderers[0]).isInstanceOf(HeaderRenderer::class.java)
}

// 개선: 최종 결과(렌더링된 HTML)만 검증 -> 내부 구현이 바뀌어도 테스트는 안 깨진다
@Test
fun `메시지를 렌더링하면 header body footer가 합쳐진 HTML을 반환한다`() {
val message = Message(header = "h", body = "b", footer = "f")
val sut = MessageRenderer()

val html = sut.render(message)

assertThat(html).isEqualTo("<header>h</header><b>b</b><footer>f</footer>")
}

4. 무엇을 검증할 것인가: 식별할 수 있는 동작 vs 구현 세부 사항​

  • 코드는 공개 API/비공개 API, 식별할 수 있는 동작/구현 세부 사항이라는 두 축으로 나뉜다. 잘 설계된 API는 이 두 축이 일치한다(공개 API = 식별할 수 있는 동작).
  • 식별할 수 있는 동작은 클라이언트(테스트 포함)가 목표를 달성하는 데 직접 필요한 연산이다. 테스트는 이것만 검증한다.
  • 구현 세부 사항은 클라이언트의 목표와 무관한 내부 처리 과정이다. 이걸 비공개로 숨기지 못하면(구현 세부 사항 유출) 테스트가 필요 이상으로 많은 것을 알아야 하고, 취약해진다.
  • 판별 규칙: 목표 하나를 이루기 위해 클래스의 연산을 2개 이상 순서대로 호출해야 한다면, 그 클래스는 구현 세부 사항을 유출하고 있다는 신호다. 내부 메서드로 감춰서 단일 진입점으로 만든다.
// 구현 세부 사항 유출: 클라이언트가 정규화와 변경을 직접 순서대로 호출해야 한다
class UserV1(var name: String) {
fun normalizeName(name: String): String = name.trim().take(50)
fun changeName(name: String) { this.name = name }
}
// client: user.changeName(user.normalizeName(newName))

// 개선: 정규화를 비공개로 감추고 단일 진입점만 공개한다
class UserV2(private var name: String) {
private fun normalizeName(name: String): String = name.trim().take(50)
fun changeName(name: String) { this.name = normalizeName(name) }
}
// client: user.changeName(newName)

5. 단위 테스트 vs 통합 테스트​

  • 단위 테스트: 단일 동작 단위를 검증하고, 빠르게 실행되며, 다른 테스트와 격리되어 실행되는 테스트. 이 세 조건 중 하나라도 못 지키면 통합 테스트다.
  • 통합 테스트: 프로세스 외부 의존성(DB, 메시지 버스, 외부 API 등)과 실제로 통합해 동작하는지 검증한다.
  • 테스트 피라미드를 따른다: 단위 테스트가 가장 많고, 통합 테스트가 중간, E2E 테스트가 가장 적어야 한다.
    • 단위 테스트로 도메인 로직의 가능한 많은 예외 상황을 다룬다.
    • 통합 테스트는 비즈니스 시나리오별 주요 흐름(happy path) 1~2개와 단위 테스트로 못 다루는 예외만 다룬다.
    • E2E 테스트는 가장 중요한 핵심 시나리오에만 최소한으로 사용한다. (느리고 유지비가 크다)
  • 고전파(Classical) 스타일을 기본으로 채택한다: 협력자를 무조건 목으로 바꾸지 않고, 공유 의존성(DB, 정적/싱글턴 상태 등)만 테스트 대역으로 대체한다. 불변 객체(값 객체)와 비공개(격리된) 의존성은 실제 인스턴스를 그대로 사용한다.

6. 테스트 구조: AAA 패턴​

  • 모든 테스트는 준비(Arrange) → 실행(Act) → 검증(Assert) 3단계로 명확히 구분한다.
@Test
fun `재고가 충분하면 구매에 성공하고 재고가 줄어든다`() {
// Arrange
val store = Store().apply { addInventory(Product.SHAMPOO, 10) }
val sut = Customer()

// Act
val success = sut.purchase(store, Product.SHAMPOO, 5)

// Assert
assertThat(success).isTrue()
assertThat(store.getInventory(Product.SHAMPOO)).isEqualTo(5)
}
  • 실행(Act) 구절은 원칙적으로 한 줄이어야 한다. 두 줄 이상 필요하다면 SUT의 공개 API 설계에 문제가 있다는 신호다(불변식이 두 호출에 걸쳐 있음 = 캡슐화 실패). 관련 로직을 SUT 내부로 옮겨 단일 호출로 캡슐화한다.
  • 검증(Assert) 구절은 여러 개일 수 있다. 단위 테스트의 "단위"는 코드 단위가 아니라 동작(behavior) 단위이므로, 하나의 동작이 여러 결과를 낳으면 그 결과를 모두 한 테스트에서 검증해도 된다. 다만 검증문이 지나치게 길어지면 값 객체에 동등성(equals/data class)을 정의해 단일 비교문으로 줄인다.
  • 테스트 대상 시스템(SUT)의 변수명은 sut로 통일한다. 협력자/입력값과 명확히 구분되어 가독성이 좋아진다.
  • 종료(teardown) 구절은 대부분의 단위 테스트에 필요 없다. 프로세스 외부 의존성이 있는 통합 테스트에만 필요하다.

6.1 피해야 할 구조 (안티 패턴)​

  • 테스트 안의 if문: 분기가 있다는 것은 테스트 하나가 여러 시나리오를 검증한다는 뜻이다. 시나리오별로 테스트를 분리한다.
  • 준비/실행/검증 구절이 여러 번 반복되는 테스트: 여러 동작 단위를 한 테스트에 몰아넣은 것이다. 단위 테스트에서는 절대 허용하지 않는다. (통합/E2E 테스트에서 속도상 불가피할 때만 예외적으로 허용)
  • 생성자에서 테스트 픽스처를 초기화하는 것: 테스트 간 결합도를 높이고(한 테스트를 위해 준비값을 바꾸면 다른 테스트가 깨짐), 테스트만 봐서는 전체 그림을 알 수 없게 만든다. 대신 비공개 팩토리 메서드로 픽스처를 만들고 각 테스트에서 명시적으로 호출한다. (단, 거의 모든 테스트가 공유하는 무거운 설정 — 예: DB 연결 — 은 예외적으로 상위 클래스 생성자에서 초기화해도 된다)
// Bad: 생성자에서 픽스처 초기화 -> 테스트 간 암묵적 결합
class CustomerTest {
private val store = Store().apply { addInventory(Product.SHAMPOO, 10) }
private val sut = Customer()
// ...
}

// Good: 비공개 팩토리 메서드로 명시적으로 생성, 테스트마다 원하는 값 지정 가능
class CustomerTest {
@Test
fun `재고가 충분하면 구매에 성공한다`() {
val store = createStoreWithInventory(Product.SHAMPOO, 10)
val sut = Customer()
// ...
}

private fun createStoreWithInventory(product: Product, quantity: Int) =
Store().apply { addInventory(product, quantity) }
}

6.2 테스트 이름 짓기​

  • 엄격한 메서드명_시나리오_기대결과 같은 형식을 강제하지 않는다. 도메인에 익숙한 비개발자에게 시나리오를 설명하듯 자연스러운 문장으로 짓는다.
  • 테스트 이름에 SUT의 메서드 이름을 넣지 않는다. 테스트는 코드가 아니라 동작(비즈니스 시나리오)을 검증하는 것이므로, 메서드 이름이 바뀌었다고 테스트 이름까지 바뀌어야 할 이유는 없다. (순수 유틸리티 함수를 테스트할 때는 예외적으로 허용)
  • 단어는 언더스코어(_)로 구분하거나 백틱(`한글 문장`)을 사용해 가독성을 높인다. (Kotlin/JUnit5는 백틱 테스트명을 지원한다)
  • 비슷한 입력·기대값 조합이 여러 개면 매개변수화된 테스트(@ParameterizedTest)로 중복을 줄인다. 다만 이름이 지나치게 포괄적으로 되면 가독성이 떨어지므로 남용하지 않는다.

7. 목(Mock)과 스텁 사용 원칙​

  • 목: 외부로 나가는 상호작용(부작용을 일으키는 호출, 예: 이메일 발송)을 모방하고 검사한다.
  • 스텁: 내부로 들어오는 상호작용(입력 데이터를 얻기 위한 호출, 예: DB 조회)을 모방만 한다. 스텁과의 상호작용을 검증하지 않는다. 스텁 호출은 최종 결과가 아니라 결과를 만들기 위한 수단이므로, 이를 검증하면 구현 세부 사항에 결합된 취약한 테스트(과잉 명세)가 된다.
// Bad: 스텁 호출 여부를 검증 (과잉 명세)
val storeStub = mock<Store> { on { hasEnoughInventory(any(), any()) } doReturn true }
sut.purchase(storeStub, Product.SHAMPOO, 5)
verify(storeStub).hasEnoughInventory(Product.SHAMPOO, 5) // 구현 세부 사항 검증 -> 취약

// Good: 최종 결과만 검증
val success = sut.purchase(storeStub, Product.SHAMPOO, 5)
assertThat(success).isTrue()
  • 시스템 내부 통신 vs 시스템 간 통신을 구분한다.
    • 시스템 내부 통신(애플리케이션 내 도메인 클래스 간의 협력)은 구현 세부 사항이다. 목으로 검증하지 않는다.
    • 시스템 간 통신(외부 애플리케이션·SMTP·메시지 버스처럼 애플리케이션 밖에서 부작용이 관찰되는 통신)은 식별할 수 있는 동작이다. 목을 사용해 호출 여부를 검증해도 된다.
  • 관리 의존성 vs 비관리 의존성을 구분한다.
    • 관리 의존성: 애플리케이션을 통해서만 접근 가능해 외부에서 통신을 관찰할 수 없는 프로세스 외부 의존성 (대표: 애플리케이션 전용 DB). 목으로 대체하지 않고 통합 테스트에서 실제 인스턴스를 사용한다.
    • 비관리 의존성: 외부에서 통신 결과를 관찰할 수 있는 프로세스 외부 의존성 (대표: SMTP, 메시지 버스, 외부 API). 목으로 대체한다.
  • 목은 통합 테스트에서만 사용한다. 순수 도메인 로직을 검증하는 단위 테스트에는 목이 등장할 필요가 없다. 테스트에 목이 필요해진다면 그 코드는 도메인 모델이 아니라 컨트롤러(애플리케이션 서비스) 영역이라는 신호다.
  • 호출 여부를 검증할 때는 두 가지를 함께 확인한다: 예상한 호출이 실제로 발생했는가, 그리고 예상치 못한 호출이 발생하지 않았는가.
  • 직접 소유하지 않은 타입(서드파티 라이브러리)은 그대로 목으로 만들지 않는다. 어댑터로 한 번 감싸고 그 어댑터를 목으로 처리한다. 서드파티 라이브러리를 업그레이드할 때 파급 효과를 어댑터 한 곳으로 제한할 수 있다.
  • 구현체가 하나뿐인 인터페이스를 "느슨한 결합"이나 OCP를 이유로 만들지 않는다. YAGNI 원칙에 어긋난다. 인터페이스는 목 처리가 필요한 비관리 의존성에만 도입한다. 관리 의존성이나 프로세스 내부 의존성은 구체 클래스를 그대로 쓴다.

8. 테스트 스타일: 출력 > 상태 > 통신​

세 가지 스타일 중 리팩터링 내성과 유지 보수성이 가장 뛰어난 순서는 출력 기반 > 상태 기반 > 통신 기반이다. 가능하면 더 상위 스타일로 작성한다.

  • 출력 기반: 순수 함수(부작용 없이 입력에 대한 출력만 반환)를 대상으로, 반환값만 검증한다. SUT의 시그니처에만 결합되므로 가장 견고하다.
  • 상태 기반: 실행 후 SUT/협력자의 상태를 검증한다. 검증 구절이 길어지기 쉬우므로, 검증 대상 클래스에 동등성(data class 등)을 정의해 단일 비교문으로 줄인다.
  • 통신 기반: 협력자를 목으로 대체해 호출 여부를 검증한다. 세 스타일 중 가장 취약하고 유지비가 크므로, 시스템 간 통신을 확인할 때만 제한적으로 사용한다.
// 출력 기반: 부작용 없는 순수 함수
fun calculateDiscount(products: List<Product>): Double = minOf(products.size * 0.01, 0.2)

@Test
fun `상품 2개의 할인율은 0_02이다`() {
val discount = calculateDiscount(listOf(Product("A"), Product("B")))
assertThat(discount).isEqualTo(0.02)
}
  • 비즈니스 로직(의사결정)을 순수 함수로 모으고, 부작용(DB 저장, 이메일 발송 등)을 담당하는 코드와 분리하는 함수형 코어 / 가변 셸(Functional Core, Imperative Shell) 구조를 지향한다. 의사결정 로직을 순수 함수로 뽑아낼수록 출력 기반 테스트로 전환할 수 있는 범위가 넓어진다.

9. 블랙박스 우선, 화이트박스는 분석용​

  • 테스트를 작성할 때는 항상 블랙박스 방식(공개 API/명세 기준, "어떻게"가 아니라 "무엇을")을 기본으로 한다. 화이트박스 테스트(내부 구현 기준)는 리팩터링 내성이 없어 깨지기 쉽다.
  • 테스트를 분석할 때(무엇을 놓쳤는지 점검할 때)는 코드 커버리지 도구로 화이트박스 관점을 활용해도 된다. 실행되지 않은 분기를 찾아서, 그 분기를 실행시키는 블랙박스 테스트를 추가하는 방식으로 조합한다.

10. 통합 테스트 / 데이터베이스 테스트 원칙​

  • 도메인 모델(비즈니스 로직)과 컨트롤러(애플리케이션 서비스, 프로세스 외부 의존성과의 조정)의 경계를 명확히 나눈다. 단위 테스트는 도메인 모델을, 통합 테스트는 컨트롤러를 대상으로 한다.
  • 계층을 불필요하게 늘리지 않는다. 계층이 많을수록 각 계층을 개별로 목 처리하려는 유혹이 커지고, 회귀 방지와 리팩터링 내성이 동시에 나빠진다. 도메인 모델 / 애플리케이션 서비스 / 인프라 정도로 단순하게 유지한다.
  • 관리 의존성(애플리케이션 DB)을 실제로 검증할 수 없는 환경이라면, 억지로 목으로 대체해 형식적인 통합 테스트를 만들지 않는다. 차라리 테스트를 작성하지 않고 도메인 모델 단위 테스트에 집중한다. 가치 없는 테스트는 스위트에 남기지 않는다.
  • 데이터베이스 스키마와 참조 데이터(코드 테이블 등)는 애플리케이션 코드와 동일하게 형상 관리 시스템에 저장하고, 마이그레이션 기반 배포 방식을 사용한다. 개발자/테스트마다 독립된 데이터베이스 인스턴스를 사용해 테스트 간 간섭을 없앤다.

11. LLM 테스트 작성 체크리스트​

  • 이 테스트는 최종 결과(반환값/상태/외부에서 관찰 가능한 부작용)를 검증하는가, 아니면 구현 세부 사항(내부 호출 순서, 비공개 메서드)을 검증하는가?
  • 준비(Arrange)-실행(Act, 한 줄)-검증(Assert) 구조가 명확히 구분되는가?
  • 테스트 안에 if문이나 반복되는 준비/실행/검증 구절이 있지 않은가?
  • 목을 사용했다면, 그 대상이 비관리 의존성(외부에서 관찰 가능한 시스템)인가? 스텁과의 호출을 검증하고 있지 않은가?
  • SUT 이름을 sut로 명확히 구분했는가?
  • 테스트 픽스처를 생성자가 아니라 비공개 팩토리 메서드로 준비했는가?
  • 테스트 이름이 구현(메서드명)이 아니라 시나리오(동작)를 설명하는가?
  • 이 테스트는 단위 테스트인가 통합 테스트인가? 프로세스 외부 의존성이 섞여 있는데 단위 테스트로 분류하고 있지 않은가?
  • 순수 함수로 뽑아낼 수 있는 로직을 출력 기반 스타일로 테스트했는가?
  • 이 테스트는 유지비를 상회하는 가치가 있는가, 아니면 커버리지를 채우기 위한 형식적인 테스트인가?

참고​

  • Vladimir Khorikov, Unit Testing: Principles, Practices, and Patterns (『단위 테스트』)
  • docs/Test/Unit-Testing