[테스트] 테스트 작성의 기본기와 분류 전략

개요

테스트 코드는 프로덕션 코드와 동일한 무게의 자산이다. 매 변경마다 새 테스트를 빠르게 작성하고, 내부 구조가 바뀌어도 잘 견디는 형태로 남겨야 한다. 잘 작성된 테스트는 동작을 보호하는 안전망이자, 코드베이스의 명세 문서이기도 하다.

이 글은 두 가지 주제를 다룬다. 첫째, 개별 테스트를 제대로 작성하는 기본기다. 구조(AAA), 이름 짓기, 단언, 픽스처 설계, 테스트 더블 다섯 가지, 그리고 좋은 테스트의 네 가지 속성을 다룬다. 둘째, 테스트 스위트 전체를 어떻게 구성하느냐의 전략이다. 피라미드, 허니컴, 안티피라미드, 단위와 통합과 계약과 E2E(End-to-End)의 역할을 다룬다.

용어정의
AAAArrange, Act, Assert 세 블록으로 테스트를 구성하는 패턴
Test Double협력자를 흉내내는 객체의 총칭. Dummy, Stub, Spy, Mock, Fake 다섯 가지로 나뉜다
SUTSystem Under Test. 한 테스트의 검증 대상이 되는 단위
Test Pyramid단위 다수, 통합 중간, E2E(End-to-End) 소수로 구성되는 권장 비율 모델
Mockist협력자를 적극적으로 Mock으로 격리하는 단위 테스트 학파
Classicist협력자를 가능한 한 실제 객체나 Fake로 두고 결과 상태를 검증하는 학파

좋은 테스트의 3블록 구조

테스트 코드는 본질적으로 세 단계로 분해된다. AAA는 코드 중심의 표현이고 Given-When-Then은 시나리오 중심의 표현이다. 의미는 동일하다.

단계AAAGiven-When-Then의미
1ArrangeGiven테스트할 상황을 준비한다
2ActWhen테스트할 동작을 실행한다
3AssertThen결과를 단언한다

세 블록이 시각적으로 구분되도록 작성한다. JUnit 5는 빈 줄과 주석으로 블록을 나눈다.

// JUnit 5 + AssertJ
@Test
@DisplayName("재고가 충분하면 주문이 성공하고 재고가 차감된다")
void placeOrder_succeeds_when_stock_is_sufficient() {
    // Arrange
    Product product = new Product(1L, 10);
    OrderService service = new OrderService(new InMemoryProductRepository(product));
 
    // Act
    OrderResult result = service.place(new Order(1L, 3));
 
    // Assert
    assertThat(result.isSuccess()).isTrue();
    assertThat(product.getStock()).isEqualTo(7);
}

Kotest는 스펙 스타일 자체가 블록을 어순으로 강제한다. BehaviorSpec은 Given/When/Then을 키워드로 제공한다.

// Kotest BehaviorSpec
class OrderServiceTest : BehaviorSpec({
    given("재고가 10개인 상품") {
        val product = Product(id = 1, stock = 10)
        val service = OrderService(InMemoryProductRepository(product))
 
        `when`("3개를 주문하면") {
            val result = service.place(Order(productId = 1, quantity = 3))
 
            then("주문이 성공하고 재고가 차감된다") {
                result.isSuccess shouldBe true
                product.stock shouldBe 7
            }
        }
    }
})

테스트 이름 짓는 법

테스트 이름은 그 자체로 명세 문장이 되어야 한다. 상황, 행위, 기대를 모두 담는다.

변형
should_X_when_Yshould_throw_OutOfStockException_when_stock_is_zero
when_Y_then_Xwhen_stock_is_zero_then_throw_OutOfStockException
한국어 …할 때 …한다재고가 0일 때 OutOfStockException을 던진다

JUnit 5는 메서드 이름이 식별자 제약을 받으므로 @DisplayName으로 자연어 문장을 붙인다. Kotlin이라면 backtick 메서드 이름도 가능하다.

// JUnit 5: @DisplayName으로 의도 표현
@Test
@DisplayName("재고가 0인 상품에 주문하면 OutOfStockException이 발생한다")
void placeOrderOnEmptyStock() { /* ... */ }
// Kotest StringSpec: 문장이 곧 이름
class OrderServiceStringSpecTest : StringSpec({
    "재고가 0인 상품에 주문하면 OutOfStockException이 발생한다" {
        val product = Product(id = 1, stock = 0)
        val service = OrderService(InMemoryProductRepository(product))
        shouldThrow<OutOfStockException> {
            service.place(Order(productId = 1, quantity = 1))
        }
    }
})

잘못된 이름은 다음과 같다.

// 메서드 이름만 반복
"testPlaceOrder" { }
 
// 기술적 표현으로 의도 불명
"placeOrder_returns_OrderResult" { }
 
// 너무 모호
"주문 테스트" { }

팀 내에서 한 가지 어순을 정하고 일관되게 쓰는 것이 가독성에 가장 중요하다. 어순이 매번 다르면 같은 시나리오의 이름이 다르게 보여 검색과 비교가 어려워진다.

단언 라이브러리

단언은 단순한 assertEquals 하나로 충분하지 않다. 의미에 맞는 단언을 선택하면 테스트 실패 시 진단이 빨라진다.

JUnit 5는 AssertJ를 함께 쓰는 것이 표준이다.

// AssertJ 주요 단언
assertThat(result).isEqualTo(7);
assertThat(names).containsExactlyInAnyOrder("Kotlin", "Java");
assertThat(names).hasSize(3);
assertThatThrownBy(() -> service.place(invalidOrder))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("quantity");
assertThat(ratio).isCloseTo(3.14, within(0.001));
assertThat(user.getEmail()).isNotNull();

Kotest matcher(매처)는 중위 함수(infix)로 영어 문장처럼 읽힌다.

// Kotest matcher 주요 단언
result shouldBe 7
names shouldContainAll listOf("Kotlin", "Java")
names shouldHaveSize 3
val ex = shouldThrow<IllegalArgumentException> { service.place(invalidOrder) }
ex.message shouldContain "quantity"
ratio shouldBe (3.14 plusOrMinus 0.001)
user.email.shouldNotBeNull()

픽스처 설계 패턴

테스트에 등장하는 협력자, 도메인 객체, 입력값을 모두 픽스처(Fixture)라 부른다.

픽스처는 매 테스트마다 새로 생성하는 것을 기본으로 한다. JUnit 5의 @BeforeEach, Kotest의 beforeTest 람다가 이 역할이다.

// JUnit 5: @BeforeEach로 매 테스트마다 초기화
class OrderServiceTest {
    private InMemoryProductRepository repository;
    private OrderService service;
 
    @BeforeEach
    void setUp() {
        repository = new InMemoryProductRepository();
        service = new OrderService(repository);
    }
}
// Kotest: beforeTest로 매 테스트마다 초기화
class OrderServiceTest : DescribeSpec({
    lateinit var repository: InMemoryProductRepository
    lateinit var service: OrderService
 
    beforeTest {
        repository = InMemoryProductRepository()
        service = OrderService(repository)
    }
})

데이터 빌더 패턴은 필드가 많은 도메인 객체에서 특정 테스트가 신경 쓰는 필드만 명시할 수 있게 한다.

// 빌더 패턴: 신경 쓰는 필드만 명시
fun anOrder() = OrderBuilder()
 
test("VIP 주문은 20% 할인") {
    val order = anOrder().vip().total(Money(10000, "KRW")).build()
    discount(order).total shouldBe Money(8000, "KRW")
}
픽스처 범위권장
테스트당 픽스처 초기화기본 선택
클래스당 공유 픽스처셋업 비용이 크고 변경되지 않을 때
정적 전역 픽스처거의 사용하지 않는다

테스트 더블 다섯 가지

종류한 줄 정의사용 예
Dummy호출 자체가 일어나지 않는 자리채우기생성자 파라미터에 강제로 들어가는 객체
Stub미리 정해진 응답만 반환API 응답 고정값 흉내
Spy호출 사실을 기록하고 검증함수가 몇 번 호출되었는지
Mock기대 호출을 미리 설정, 그대로 호출되어야 통과부수 효과 검증
Fake단순화된 실제 구현인메모리 저장소

Stub은 미리 정한 응답을 돌려준다. 어떻게 호출되었는지는 검증하지 않는다.

// MockK Stub
val productRepo: ProductRepository = mockk()
every { productRepo.findById(any()) } returns Product(id = 1, stock = 10)
// Mockito Stub
ProductRepository productRepo = mock(ProductRepository.class);
when(productRepo.findById(anyLong())).thenReturn(new Product(1L, 10));

Mock은 기대 호출을 미리 설정하고 그대로 호출되지 않으면 테스트가 실패한다. 핵심은 결과 상태가 아니라 어떤 호출이 일어났는지를 본다는 점이다.

// MockK Mock: 상호작용 검증
val notifier: Notifier = mockk()
every { notifier.send(any()) } returns Unit
service.place(order)
verify(exactly = 1) { notifier.send(match { it.userId == 1L }) }
// Mockito Mock: 상호작용 검증
Notifier notifier = mock(Notifier.class);
service.place(order);
verify(notifier, times(1)).send(argThat(msg -> msg.getUserId() == 1L));

Fake는 실제와 같은 인터페이스를 갖되 더 단순한 구현체다.

// Fake: 인메모리 저장소
class InMemoryProductRepository : ProductRepository {
    private val store = mutableMapOf<Long, Product>()
    override fun save(product: Product) { store[product.id] = product }
    override fun findById(id: Long): Product? = store[id]
}

Fake는 실제 동작을 갖는다. Mock과 달리 호출 순서나 횟수를 미리 정의할 필요가 없다. 테스트는 결과 상태만 검증하면 되므로 리팩토링 내성이 가장 높다.

좋은 테스트의 네 가지 속성

속성의미
회귀 방어(Protection against regressions)변경이 동작을 망가뜨렸을 때 잡아낼 수 있는가
리팩토링 내성(Resistance to refactoring)내부 구조 변경에도 깨지지 않는가
빠른 피드백(Fast feedback)충분히 빠르게 돌아가는가
유지보수성(Maintainability)읽기 쉽고 고치기 쉬운가

회귀 방어를 극대화하면 E2E 테스트가 되어 피드백 속도가 느려진다. 리팩토링 내성을 극대화하면 너무 타이트한 단언이 되어 회귀 방어가 약해진다. 트레이드오프를 인지하고 의식적으로 균형을 잡는 것이 테스트 설계다.

테스트 분류와 피라미드

TDD(Test-Driven Development)가 가장 유용하게 쓰이는 곳은 단위 테스트이지만, 실제 시스템은 단위 테스트만으로 충분히 검증되지 않는다. 단위를 제외한 영역은 통합, 계약, E2E 테스트로 검증한다.

테스트 피라미드(Test Pyramid)는 Mike Cohn이 정리한 비율 모델이다.

비율속도신뢰도
단위70~80%밀리초부분
통합15~25%수백ms~초부분 결합
E2E5~10%수초~수십초전체

피라미드 모델이 유일한 권장 비율은 아니다. Spotify가 정리한 테스트 허니컴(Test Honeycomb) 모델은 마이크로서비스 환경에서 통합 테스트 비중을 더 키운다.

모델단위통합E2E
피라미드70~80%15~25%5~10%
허니컴20~30%50~60%10~20%
안티피라미드(아이스크림콘)10~20%20~30%50~70%

허니컴이 통합 비중을 키우는 근거는 마이크로서비스 환경에서 한 서비스의 단위 테스트가 시스템 동작을 보장하지 못한다는 관찰이다. 서비스 간 결합 자체가 본질인 영역이라 통합 테스트의 신뢰도가 단위보다 높다.

Mockist와 Classicist 학파

단위 테스트 안에서도 학파가 갈린다. 런던 학파(London school)라고도 불리는 Mockist는 협력자를 적극적으로 Mock으로 격리한다. 시카고 학파(Chicago school)라고도 불리는 Classicist는 협력자를 가능한 한 실제 객체나 Fake로 두고 결과 상태를 검증한다.

항목Mockist(런던)Classicist(시카고)
협력자Mock으로 격리실제 객체 또는 Fake
검증호출 검증결과 상태 검증
리팩토링 내성낮음높음
설계 견인강함보통
대표 도구Mockito, MockK인메모리 Fake
적합 영역어댑터, 외부 통합도메인 코어, 계산

대다수의 실무 코드베이스는 두 학파를 혼용한다. 외부 호출이 본질인 자리에서는 Mockist, 도메인 규칙이 본질인 자리에서는 Classicist다. 도메인 코어를 Mock으로 격리해서 검증하면 리팩토링 내성이 사라지고, 외부 어댑터를 Fake로만 검증하면 호출 의도가 흐려진다.

Outside-In은 런던 학파의 기본이다. 가장 바깥 계층(Controller, UI)의 수락 테스트부터 작성한다. 협력자를 모두 Mock으로 두고 안쪽 협력자를 차례로 채워나간다. Inside-Out은 시카고 학파의 기본이다. 도메인 코어부터 단위 TDD(테스트 주도 개발)로 시작해 그 위에 어댑터와 서비스를 쌓는다.

통합 테스트와 Testcontainers

통합 테스트는 여러 단위가 진짜로 결합된 상태에서의 동작을 검증한다. 가장 자주 작성하는 것은 DB(데이터베이스) 포함 통합 테스트다.

Testcontainers 패턴이 표준이다. Docker로 실제 DB를 띄워 테스트한다. H2 같은 인메모리 DB로 대체하는 방식은 운영 DB와 SQL 방언이 달라 의도치 않는 테스트 성공이 발생할 수 있다.

// Kotest + Testcontainers
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest : FunSpec({
    companion object {
        @Container
        @ServiceConnection
        val mysql = MySQLContainer<Nothing>("mysql:8.0").apply {
            withDatabaseName("test")
        }
    }
 
    test("저장 후 조회되어야 한다") {
        val saved = orderRepository.save(Order(productId = 1, quantity = 3))
        val found = orderRepository.findById(saved.id).orElse(null)
        found.shouldNotBeNull()
        found.quantity shouldBe 3
    }
})
// JUnit 5 + Testcontainers
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest {
 
    @Container
    @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("test");
 
    @Autowired
    OrderRepository orderRepository;
 
    @Test
    void 저장_후_조회되어야_한다() {
        Order saved = orderRepository.save(new Order(1L, 3));
        Order found = orderRepository.findById(saved.getId()).orElse(null);
        assertThat(found).isNotNull();
        assertThat(found.getQuantity()).isEqualTo(3);
    }
}

통합 테스트의 격리 전략은 컨테이너 공유가 기본이다. 여러 테스트가 한 컨테이너를 공유하되 매 테스트 후 데이터를 정리한다. 컨테이너 부팅 비용을 모듈당 1회로 줄인다.

BDD(Behavior-Driven Development)와 ATDD(Acceptance Test-Driven Development)는 수락 기준을 자연어 DSL(도메인 특화 언어)로 표현한다. 비즈니스가 읽을 수 있는 시나리오가 핵심이다.

Feature: 주문 생성
  Scenario: 재고가 충분할 때
    Given 상품 P1의 재고가 10개이다
    When 사용자가 P1을 3개 주문한다
    Then 주문이 성공한다
    And P1의 재고는 7개가 된다

어떤 도구를 어디에 쓰는가

영역Java 도구Kotlin 도구학파
도메인 단위 테스트JUnit 5 + AssertJKotest + matchersClassicist
어댑터 단위 테스트JUnit 5 + MockitoKotest + MockKMockist
Repository 통합 테스트Spring Boot Test + TestcontainersKotest + Testcontainers-
외부 HTTP 통합 테스트WireMock + RestAssuredWireMock + KotestMockist
계약 테스트Pact 또는 Spring Cloud ContractPact 또는 Spring Cloud Contract-
E2E 테스트Playwright 또는 CypressPlaywright 또는 Cypress-