Classicist vs Mockist
개요
TDD를 실천하는 방식은 크게 두 갈래로 나뉜다. 갈림길은 하나다. 테스트 대상이 의존하는 협력 객체를 실제 구현으로 둘 것인가, 목(mock)으로 대체할 것인가.
| 명칭 | 다른 이름 | 협력 객체 | 검증 대상 |
|---|---|---|---|
| Classicist | Chicago 학파, Detroit 학파, Inside-Out | 가능한 한 실제 객체 | 상태(state) |
| Mockist | London 학파, Outside-In, Interaction 테스트 | 대부분 목 | 상호작용(interaction) |
두 이름의 유래는 지역이다. Classicist는 Kent Beck과 초기 XP 커뮤니티(디트로이트의 Chrysler C3 프로젝트)에서, Mockist는 런던의 Extreme Tuesday Club에서 Steve Freeman·Nat Pryce 등이 목 객체를 정리하며 형성됐다.
같은 요구, 다른 테스트
주문을 확정하면 재고를 차감하고 주문 상태를 바꾸는 OrderService가 있다고 하자.
Classicist 스타일
InventoryRepository의 실제 구현(또는 인메모리 Fake)을 그대로 쓰고, 작업이 끝난 뒤의 상태를 검증한다.
@Test
void 주문을_확정하면_재고가_차감된다() {
InventoryRepository inventory = new InMemoryInventoryRepository();
inventory.save(new Stock("COFFEE", 10));
OrderService service = new OrderService(inventory);
service.confirm(new Order("COFFEE", 3));
assertThat(inventory.findBy("COFFEE").quantity()).isEqualTo(7);
}
검증 문장이 “재고가 7이 되어야 한다"는 결과를 말한다. OrderService가 재고를 어떤 메서드로 몇 번 호출해 차감했는지는 테스트가 알지 못하고, 알 필요도 없다.
Mockist 스타일
협력 객체를 목으로 세우고, 어떤 호출이 일어났는지를 검증한다.
@Test
void 주문을_확정하면_재고_차감을_요청한다() {
InventoryRepository inventory = mock(InventoryRepository.class);
OrderService service = new OrderService(inventory);
service.confirm(new Order("COFFEE", 3));
verify(inventory).decrease("COFFEE", 3);
}
검증 문장이 “decrease("COFFEE", 3)가 호출되어야 한다"는 협력 방식을 말한다. InventoryRepository 구현은 아직 존재하지 않아도 되고, 실제로 Mockist 스타일에서는 존재하지 않는 것이 정상이다.
설계를 만들어 가는 방향
두 스타일의 진짜 차이는 검증 문법이 아니라 개발이 진행되는 방향이다.
Classicist는 안쪽부터 쌓는다. 값 객체와 도메인 객체처럼 의존이 없는 것부터 실제로 만들고, 그것들이 동작하게 된 뒤 위 계층을 얹는다. 아래층이 실제로 동작하므로 조립 시점에 놀랄 일이 적지만, 위에서 실제로 어떤 인터페이스가 필요한지는 나중에 알게 된다.
Mockist는 바깥부터 파고든다. 사용자 시나리오에서 시작해 “이 객체에게 필요한 협력자는 무엇인가"를 물으며 인터페이스를 발명하고, 그 인터페이스를 목으로 세운 뒤 다음 사이클에서 실제 구현으로 내려간다. 필요 없는 코드를 만들 위험이 줄고 역할(role) 중심 설계가 유도되지만, 아래층이 목이 약속한 대로 동작하는지는 별도의 통합 테스트로 확인해야 한다.
Freeman과 Pryce가 Growing Object-Oriented Software, Guided by Tests에서 정리한 방식이 후자이며, 이 책은 목을 “협력자를 흉내 내는 도구"가 아니라 역할을 발견하는 설계 도구로 위치시킨다.
비교
| 항목 | Classicist | Mockist |
|---|---|---|
| 테스트 단위 | 여러 실제 객체가 함께 동작하는 덩어리 | 클래스 하나 |
| 검증 방식 | 상태 검증 | 행위 검증 |
| 실패 시 원인 특정 | 어느 객체 탓인지 좁히기 어려움 | 실패 지점이 명확 |
| 리팩터링 내성 | 강함. 내부 구조를 바꿔도 결과가 같으면 통과 | 약함. 협력 방식이 바뀌면 테스트가 깨짐 |
| 테스트 실행 속도 | 상대적으로 느림 | 빠름 |
| 설계 유도 | 도메인 모델 중심 | 역할과 인터페이스 중심 |
| 통합 지점 검증 | 테스트 안에 일부 포함됨 | 별도 테스트 필요 |
| 미완성 협력자 | 필요함 | 없어도 진행 가능 |
결합도라는 대가
Mockist 스타일의 가장 큰 비용은 테스트가 구현 세부에 묶인다는 점이다. verify(inventory).decrease("COFFEE", 3)은 “재고가 3만큼 줄어야 한다"가 아니라 “decrease라는 이름의 메서드를 이 인자로 호출해야 한다"는 뜻이다. 동일한 결과를 내는 다른 구조로 리팩터링하면 동작이 멀쩡해도 테스트가 깨진다.
이때 테스트는 회귀 안전망이 아니라 리팩터링의 저항이 된다. TDD를 향한 비판 상당수가 실제로는 이 지점을 겨냥한다. TDD 개요에서 정리한 “TDD is dead” 논쟁의 Mock 남용 쟁점이 여기에 해당한다.
반대로 Classicist 스타일의 비용은 범위 확산이다. 실제 협력 객체를 쓰므로 하위 객체의 버그가 상위 테스트를 무더기로 깨뜨린다. 실패 목록이 길어지고 원인 특정에 시간이 든다. 다만 이 문제는 아래층 테스트가 먼저 존재하면 크게 완화된다. 하위 테스트도 함께 깨지므로 원인이 아래에 있다는 사실이 바로 드러난다.
선택 가이드
| 상황 | 권장 |
|---|---|
| 계산·판정 로직이 중심인 도메인 코드 | Classicist. 상태 검증이 자연스럽고 리팩터링 내성이 높다 |
| 외부 시스템(결제 API, 메일 발송, 시계) 경계 | 목 또는 Stub 필수. 실제 호출은 느리거나 부작용을 만든다 |
| 반환값 없이 부작용만 있는 협력 | 행위 검증 외에 방법이 없다. verify 사용 |
| 아직 구현되지 않은 하위 계층과 병행 개발 | Mockist. 인터페이스만 정하고 위층을 먼저 진행 |
| 레거시 코드에 테스트를 붙이는 중 | 목으로 의존을 끊어 테스트 가능한 지점을 먼저 확보 |
| 값 객체(Value Object) | 어느 쪽이든 목을 쓰지 않는다. 실제 객체를 생성 |
실무의 기본형은 한쪽을 고르는 것이 아니라 경계를 기준으로 나누는 것이다. 도메인 내부는 실제 객체로 조립해 상태를 검증하고, 프로세스 밖으로 나가는 의존(DB, 외부 API, 시간, 랜덤)만 테스트 더블로 대체한다. 이 배치는 리팩터링 내성과 실행 속도를 동시에 확보한다.
예외도 있다. Fowler가 드는 사례는 캐시다. 캐시는 히트했는지 미스했는지가 상태에 드러나지 않는 것이 존재 이유이므로, 강경한 Classicist라도 이 경우에는 행위 검증을 택하는 편이 낫다. 판단은 학파가 아니라 대상의 성격을 따른다.
Fowler의 입장
Fowler 본인은 “구식 Classic TDDer"라고 밝히면서 두 가지 이유를 든다. 하나는 테스트를 쓸 때 어떻게 하는지가 아니라 무엇이 나오는지에 집중하게 된다는 점이고, 다른 하나는 테스트를 구현에 결합시키는 결과를 우려한다는 점이다. Mockist 프로그래머를 관찰했을 때 기대를 작성하기 위해 계속 구현 방식을 생각해야 한다는 점이 부자연스럽게 느껴졌다고 적었다.
동시에 그는 자신이 Mockist 스타일을 장난감 수준 이상으로 시도해 본 적이 없다는 한계를 인정하고, 다음 두 상황이라면 Mockist 스타일을 시도해 보라고 권한다.
- 테스트가 깔끔하게 깨지지 않아 실패 원인을 찾는 데 시간을 많이 쓰고 있을 때 (Classic 스타일에서 테스트 단위를 더 잘게 나누는 것으로도 개선 가능)
- 객체에 행위가 충분히 담기지 않을 때. Mockist 스타일은 행위가 풍부한 객체를 만들도록 유도하는 경향이 있다
용어 자체에 대한 유보도 함께 기록해 둘 만하다. 상당수의 Mockist TDDer는 classical/mockist라는 구분 자체를 좋아하지 않으며, 두 스타일을 나누는 것이 유용하다고 보지 않는다.
결국 판단 기준으로 남는 것은 하나다. 테스트를 고치지 않고 구현을 바꿀 수 있는가.
관련 문서
- TDD 개요 - TDD의 정의와 이 논쟁의 배경
- 테스트 더블 - Mock과 Stub의 정확한 구분
- 테스트 피라미드와 단위의 범위 - 이 구분의 다른 이름인 solitary vs sociable, 그리고 “단위"의 정의 문제
- Red-Green-Refactor - 사이클 각 단계의 규칙
- Repository와 Service - 테스트 더블로 대체되는 대표적 경계
참고 자료
- Martin Fowler, Mocks Aren’t Stubs - 두 학파를 정의한 글
- Martin Fowler, Unit Test - solitary vs sociable 테스트 구분
- Steve Freeman, Nat Pryce, Growing Object-Oriented Software, Guided by Tests (2009)
- Vladimir Khorikov, Unit Testing: Principles, Practices, and Patterns (2020) - 리팩터링 내성 개념