[테스트] 테스트 작성의 기본기와 분류 전략
개요
테스트 코드는 프로덕션 코드와 동일한 무게의 자산이다. 매 변경마다 새 테스트를 빠르게 작성하고, 내부 구조가 바뀌어도 잘 견디는 형태로 남겨야 한다. 잘 작성된 테스트는 동작을 보호하는 안전망이자, 코드베이스의 명세 문서이기도 하다.
이 글은 두 가지 주제를 다룬다. 첫째, 개별 테스트를 제대로 작성하는 기본기다. 구조(AAA), 이름 짓기, 단언, 픽스처 설계, 테스트 더블 다섯 가지, 그리고 좋은 테스트의 네 가지 속성을 다룬다. 둘째, 테스트 스위트 전체를 어떻게 구성하느냐의 전략이다. 피라미드, 허니컴, 안티피라미드, 단위와 통합과 계약과 E2E(End-to-End)의 역할을 다룬다.
| 용어 | 정의 |
|---|---|
| AAA | Arrange, Act, Assert 세 블록으로 테스트를 구성하는 패턴 |
| Test Double | 협력자를 흉내내는 객체의 총칭. Dummy, Stub, Spy, Mock, Fake 다섯 가지로 나뉜다 |
| SUT | System Under Test. 한 테스트의 검증 대상이 되는 단위 |
| Test Pyramid | 단위 다수, 통합 중간, E2E(End-to-End) 소수로 구성되는 권장 비율 모델 |
| Mockist | 협력자를 적극적으로 Mock으로 격리하는 단위 테스트 학파 |
| Classicist | 협력자를 가능한 한 실제 객체나 Fake로 두고 결과 상태를 검증하는 학파 |
좋은 테스트의 3블록 구조
테스트 코드는 본질적으로 세 단계로 분해된다. AAA는 코드 중심의 표현이고 Given-When-Then은 시나리오 중심의 표현이다. 의미는 동일하다.
| 단계 | AAA | Given-When-Then | 의미 |
|---|---|---|---|
| 1 | Arrange | Given | 테스트할 상황을 준비한다 |
| 2 | Act | When | 테스트할 동작을 실행한다 |
| 3 | Assert | Then | 결과를 단언한다 |
세 블록이 시각적으로 구분되도록 작성한다. 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_Y | should_throw_OutOfStockException_when_stock_is_zero |
| when_Y_then_X | when_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~초 | 부분 결합 |
| E2E | 5~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 + AssertJ | Kotest + matchers | Classicist |
| 어댑터 단위 테스트 | JUnit 5 + Mockito | Kotest + MockK | Mockist |
| Repository 통합 테스트 | Spring Boot Test + Testcontainers | Kotest + Testcontainers | - |
| 외부 HTTP 통합 테스트 | WireMock + RestAssured | WireMock + Kotest | Mockist |
| 계약 테스트 | Pact 또는 Spring Cloud Contract | Pact 또는 Spring Cloud Contract | - |
| E2E 테스트 | Playwright 또는 Cypress | Playwright 또는 Cypress | - |