한 줄 정의
테스트 대역(test double)이란 실제 구현 대신 쓸 수 있는 객체나 함수입니다. 느리거나 비결정적인 의존성을 우회하게 해주지만, 충실성(fidelity)을 잃으면 테스트가 가치를 잃으므로 되도록 실제 구현 → 안 되면 가짜 객체 → 그다음 스텁·상호작용 테스트 순으로 신중하게 골라야 합니다.
쉽게 말하면
테스트 대역은 영화의 스턴트 대역입니다. 위험한 장면에서 주연 배우 대신 스턴트맨이 뛰어내리듯, 실제로 부르면 돈이 나가거나(신용카드 결제) 네트워크가 필요한 의존성을 대역이 대신 연기합니다.
문제는 어떤 대역을 쓰느냐입니다. 좋은 스턴트 대역은 주연과 체격·움직임이 똑같아서 관객이 눈치채지 못합니다 — 가짜 객체(fake) 가 이쪽입니다. 반대로 그냥 마네킹을 세워두고 “여기서 떨어진 셈 치자”고 약속하는 게 스텁(stub) 이고, 카메라 대신 “배우가 3번 점프했는지”만 체크하는 게 상호작용 테스트(interaction test) 입니다. 마네킹은 빠르고 편하지만 진짜 배우가 그 동작을 할 수 있는지는 영영 보장하지 못합니다.
이 장 전체는 결국 “대역을 너무 많이, 너무 마네킹처럼 쓰면 테스트가 진짜 영화(프로덕션)와 따로 논다” 는 하나의 경고입니다. 구글이 한때 모의 객체에 중독됐다가 “되도록 진짜를 쓰자”로 돌아선 이야기가 그 증거입니다.
왜 중요한가?
단위 테스트는 단순한 코드라면 부담이 없지만, 외부 서버에 요청을 보내고 응답을 DB에 저장하는 함수처럼 의존성이 얽히면 작성이 어려워집니다. 이런 테스트를 수백~수천 개 돌리면 전체 수행에 몇 시간씩 걸리고, 네트워크 실패나 테스트끼리 데이터를 덮어쓰는 일이 생겨 테스트 스위트가 불규칙하게 실패하기 시작합니다.
테스트 대역은 이 의존성 문제를 우회하게 해줍니다. 실제 구현보다 훨씬 가벼워서, 여러 프로세스나 기기를 연동해야 하는 코드처럼 원래 작은 테스트로는 검증하지 못하던 영역까지 빠르고 안정적인 작은 테스트로 끌어옵니다.
하지만 대역은 잘못 쓰면 생산성을 크게 떨어뜨립니다. 구글이 모의 객체 프레임워크를 처음 도입했을 때는 만능 요술램프처럼 보였습니다. 의존 모듈에 신경 쓰지 않고 원하는 코드 조각에만 집중하는 테스트를 셀 수 없이 양산했죠. 몇 해가 지나자 대가를 치렀습니다 — 테스트는 쉽게 짰지만 버그는 잘 못 찾았고 끊임없이 보수해야 했습니다. 그래서 방향을 틀어, 오늘날 많은 엔지니어가 모의 객체를 피하고 실제에 더 가까운 테스트를 작성합니다.
핵심 내용
대역을 둘러싼 세 가지 절충 — testability·applicability·fidelity
대역을 도입하면 복잡한 절충 문제 세 가지가 따라옵니다. 이 장 전체를 관통하는 축이므로 먼저 정리합니다.
| 개념 | 무엇인가 | 핵심 함의 |
|---|---|---|
| 테스트 용이성(testability) | 코드베이스가 대역으로 교체 가능하게 설계됐는지 | DB를 직접 호출하는 코드는 대역을 못 끼움. 테스트를 염두에 두지 않은 코드는 리팩터링이 선행돼야 함 |
| 적용 가능성(applicability) | 대역을 제대로 vs 잘못 썼을 때의 결과 | 제대로 쓰면 엔지니어링 속도가 오르지만, 잘못 쓰면 깨지기 쉽고 복잡하고 효율 나쁜 테스트로 전락 |
| 충실성(fidelity) | 대역이 실제 구현의 행위와 얼마나 유사한가 | 실제와 전혀 다르게 동작하는 대역은 무가치. 단 100% 충실도 비현실적 — 보통 실제보다 훨씬 단순해야 함 |
충실성에는 미묘한 균형이 있습니다. 입력을 무시하고 항상 똑같은 결과만 반환하는 대역은 쓸모가 없지만, 그렇다고 100% 충실하게 만드는 것도 비현실적입니다. 완벽하게 충실하지 못한 대역만으로도 테스트는 충분한 효과를 냅니다. 대역으로 채우지 못하는 부분은 실제 구현을 이용하는 더 큰 범위의 테스트로 보완하면 됩니다.
대역을 쓰려면 — 이어주기(seam)와 의존성 주입
단위 테스트를 고려해 짠 코드를 테스트하기 쉽다(testable) 고 하고, 제품 코드 차원에서 대역을 끼워 넣을 길을 터주는 것을 이어주기(seam) 라 합니다. 프로덕션에서 쓰는 의존 대상을 테스트에서 다른 대상으로 교체할 수 있게 해주면 됩니다.
대표적인 이어주기 기술이 의존성 주입(dependency injection, DI) 입니다. 필요한 의존성을 클래스 내부에서 직접 생성하지 않고 외부에서 건네받는 방식입니다.
// 코드 13-1: 신용카드 서비스 — creditCardService를 내부에서 쓴다
class PaymentProcessor {
private CreditCardService creditCardService;
...
boolean makePayment(CreditCard creditCard, Money amount) {
if (creditCard.isExpired()) { return false; }
boolean success =
creditCardService.chargeCreditCard(creditCard, amount);
return success;
}
}// 코드 13-4: 의존성 주입 — 생성자로 건네받는다 (내부 생성 X)
class PaymentProcessor {
private CreditCardService creditCardService;
PaymentProcessor(CreditCardService creditCardService) {
this.creditCardService = creditCardService;
}
...
}이렇게 하면 프로덕션에서는 외부 서버와 통신하는 실제 구현을, 테스트에서는 대역을 건넬 수 있습니다. 가장 기초적인 대역은 인터페이스를 구현하되 아무 일도 하지 않는 클래스입니다.
// 코드 13-2: 기초적인 테스트 대역 — 무조건 true 반환
class TestDoubleCreditCardService implements CreditCardService {
@Override
public boolean chargeCreditCard(CreditCard creditCard, Money amount) {
return true;
}
}
// 코드 13-5: 테스트 대역 건네기
PaymentProcessor paymentProcessor =
new PaymentProcessor(new TestDoubleCreditCardService());이 단순한 대역만으로도 makePayment()의 일부 로직(만료된 카드를 올바르게 거르는지)을 실제 신용카드 서비스에 접근하지 않고 검증할 수 있습니다.
// 코드 13-3: 테스트 대역 적용
@Test public void cardIsExpired_returnFalse() {
boolean success = paymentProcessor.makePayment(EXPIRED_CARD, AMOUNT);
assertThat(success).isFalse();
}동적 타입 언어에서는 DI 중요도가 낮다
파이썬·자바스크립트 같은 동적 타입 언어에서는 개별 함수나 메서드를 동적으로 교체할 수 있어 의존성 주입의 중요도가 낮습니다. 이 능력을 쓰면 실제 구현 중 당장 활용할 수 없는 함수·메서드만 골라 테스트가 덮어쓸 수 있기 때문입니다.
모의 객체 프레임워크 — 편리함이 곧 함정
모의 객체 프레임워크(mocking framework) 는 테스트 대역을 쉽게 만들어주는 라이브러리입니다. 대역이 필요할 때마다 새 클래스를 정의하지 않아도 되므로 보일러플레이트를 줄여줍니다. 여기서 모의 객체(mock) 는 구체적인 동작 방식을 테스트가 지정할 수 있는 대역을 말합니다.
// 코드 13-6: 모의 객체 프레임워크 (Mockito)
class PaymentProcessorTest {
...
PaymentProcessor paymentProcessor;
// 단 한 줄로 CreditCardService의 테스트 대역을 생성
@Mock CreditCardService mockCreditCardService;
@Before public void setUp() {
paymentProcessor = new PaymentProcessor(mockCreditCardService);
}
@Test public void chargeCreditCardFails_returnFalse() {
// chargeCreditCard()를 호출하면 무조건 false 반환. any()는 입력 무관.
when(mockCreditCardService.chargeCreditCard(any(), any()))
.thenReturn(false);
boolean success = paymentProcessor.makePayment(CREDIT_CARD, AMOUNT);
assertThat(success).isFalse();
}
}자바는 Mockito, C++는 Googletest의 googlemock, 파이썬은 unittest.mock을 씁니다. 업계에서 많이 쓰이는 언어라면 대부분 모의 객체 프레임워크가 존재합니다.
편리함의 대가
모의 객체 프레임워크 덕분에 대역을 손쉽게 쓸 수 있지만, 과용하면 코드베이스를 유지보수하기 어렵게 된다는 심각한 부작용이 있습니다. 손쉬움이 곧 남용을 부른다는 게 이 장의 핵심 경고입니다.
세 가지 활용 기법 — fake·stub·interaction
대표적인 대역 활용 기법은 셋입니다. 각각이 무엇을 하는지부터 명확히 구분해야 상황에 맞는 하나를 고를 수 있습니다.
flowchart TD A["테스트 대역이 필요하다"] --> B["가짜 객체<br/>(fake)"] A --> C["스텁<br/>(stub)"] A --> D["상호작용 테스트<br/>(interaction test)"] B --> B1["실제와 비슷하게 동작하되<br/>가볍게 구현<br/>예: 인메모리 DB"] C --> C1["없던 행위를 덧씌움<br/>반환값을 '뭉갠다'<br/>when(...).thenReturn(...)"] D --> D1["호출하지 않고<br/>'어떻게' 호출되는지 검증<br/>verify(...)"]
가짜 객체(fake) — 가장 우선하는 대역
가짜 객체(fake) 는 제품 코드로는 적합하지 않지만 실제 구현과 비슷하게 동작하도록 가볍게 구현한 대역입니다. 인메모리 데이터베이스가 대표적입니다. 대상 시스템은 자신이 쓰는 게 실제 구현인지 가짜 객체인지 구분할 수 없어야 합니다.
// 코드 13-7: 간단한 가짜 객체 — 실제 객체처럼 상태를 가진다
AuthorizationService fakeAuthorizationService =
new FakeAuthorizationService();
AccessManager accessManager = new AccessManager(fakeAuthorizationService);
// 모르는 사용자의 ID로는 접근을 불허합니다.
assertFalse(accessManager.userHasAccess(USER_ID));
// 사용자 ID를 인증 서비스에 등록한 다음에는 접근을 허용합니다.
fakeAuthorizationService.addAuthorizedUser(new User(USER_ID));
assertThat(accessManager.userHasAccess(USER_ID)).isTrue();// 코드 13-11: 가짜 파일시스템 — 실제 구현과 같은 인터페이스를 구현
public class FakeFileSystem implements FileSystem {
// 디스크 I/O 없이 파일을 메모리에 저장합니다.
private Map<String, String> files = new HashMap<>();
@Override
public void writeFile(String fileName, String contents) {
files.add(fileName, contents);
}
@Override
public String readFile(String fileName) {
String contents = files.get(fileName);
// 실제 구현이 파일을 못 찾을 때 예외를 던지므로 가짜도 똑같이 던진다.
if (contents == null) { throw new FileNotFoundException(fileName); }
return contents;
}
}가짜 객체가 강력한 이유는 실제 객체의 단점(느림·비결정성)을 제거하면서도 충실성을 유지하기 때문입니다. 가짜 객체 하나만 잘 만들어도 그 API의 테스트 경험이 극적으로 좋아지고, 모든 API로 확장하면 조직 전반의 엔지니어링 속도가 올라갑니다.
다만 주의점이 있습니다.
- 작성·유지보수 비용이 큽니다. 실제 구현과 비슷하게 동작해야 해서 도메인 지식이 필요하고, 실제 객체 행위가 바뀔 때마다 발맞춰 갱신해야 합니다. 그래서 가짜 객체는 실제 구현을 담당하는 팀이 만들고 관리해야 합니다. 사용자가 수백 명쯤 되어야 들이는 비용이 정당화됩니다.
- 충실성은 해당 테스트의 관점에서만 보장하면 됩니다. 해시 API의 가짜 객체라면 실제와 똑같은 해시값을 반환할 필요는 없습니다 — 대부분의 테스트는 고유하기만 하면 만족하니까요. API 명세에 명시되지 않은 부분은 100% 충실하지 않아도 됩니다.
- 가짜 객체도 테스트가 있어야 합니다. 실제 구현이 변경되면 가짜가 실제와 달라질 수 있습니다. 실제 구현과 가짜 객체 둘 다를 대상으로 하는 공개 인터페이스 검증 테스트(계약 테스트, contract test)를 작성해 이를 막습니다.
가짜 객체를 이용할 수 없다면
적당한 가짜가 없으면 먼저 API 소유자에게 만들어 달라고 요청합니다. 소유자가 만들 수 없다면 직접 작성할 수 있습니다 — 해당 API를 감싸는 클래스를 하나 만들어 모든 호출이 이를 거치게 하고, 인터페이스는 똑같지만 실제 API를 쓰지 않는 클래스(= 가짜 객체)를 한 벌 더 준비합니다. 필요한 일부 API만 지원하면 되므로 전체를 지원하는 가짜보다 만들기 수월합니다. 그래도 안 되면 실제 구현을 쓰거나 다른 대역 기법으로 넘어갑니다.
스텁(stub) — 반환값을 ‘뭉개기’
스텁(stub) 은 원래는 없던 행위를 함수에 덧씌우는 기법입니다. 대상 함수가 반환할 값을 테스트가 직접 지정하는 것을 ‘반환값을 뭉갠다(스텁한다)‘고 표현합니다. (옮긴이 주: stub은 ‘쓰다 남은 물건의 토막’이라는 뜻으로, 원래 동작을 무시하고(뭉개고) 테스트가 지정한 동작을 수행하게 하는 모습을 빗댔습니다.)
// 코드 13-12: 응답을 시뮬레이션하기 위해 스텁 사용
@Test public void getTransactionCount() {
transactionCounter = new TransactionCounter(mockCreditCardServer);
// 스텁을 이용해 트랜잭션 3개를 반환합니다.
when(mockCreditCardServer.getTransactions()).thenReturn(
newList(TRANSACTION_1, TRANSACTION_2, TRANSACTION_3));
assertThat(transactionCounter.getTransactionCount()).isEqualTo(3);
}스텁은 특정 함수가 특정 값을 반환하도록 하여 대상 시스템을 원하는 상태로 몰아넣을 때 제격입니다. 실제 구현이나 가짜로는 빈 거래 목록을 반환시키거나 드문 오류를 일으키기가 불가능할 수 있는데, 스텁이면 테스트 코드에서 동작을 정의할 수 있습니다.
스텁 과용의 세 가지 위험
적용이 쉬워서 실제 구현이 여의치 않을 때마다 엔지니어를 유혹하지만, 과용하면 유지보수 부담이 커집니다.
- 불명확해진다 — 행위를 덧씌우는 코드가 추가돼 테스트 의도를 파악하기 어려워집니다. 스텁한 이유를 알려고 실제 시스템 코드를 들춰봐야 한다면 스텁이 부적합하다는 결정적 신호입니다.
- 깨지기 쉬워진다 — 스텁은 대상 시스템의 내부 구현 방식을 테스트에 드러냅니다. 내부가 바뀌면 테스트도 함께 고쳐야 합니다. 공개 API가 아닌 한 내부 변경에 영향받지 않아야 좋은 테스트입니다.
- 테스트 효과가 감소한다 — 원래 행위를 뭉개버리면 그 함수가 실제 구현과 똑같이 동작하는지 보장할 방법이 사라집니다.
when(stubCalculator.add(1, 2)).thenReturn(3)처럼 명세를 하드코딩하면, 그 명세가 올바른지조차 검증하지 못합니다. 상태 저장 방법도 사라져 시스템 특성 일부를 테스트하기 어려워집니다.
스텁 과용의 전형적인 모습과, 그것을 걷어낸 모습을 비교하면 차이가 분명합니다.
// 코드 13-13: 스텁을 과용하는 예 — 처리 과정이 테스트 코드에 줄줄이 노출
@Test public void creditCardIsCharged() {
paymentProcessor =
new PaymentProcessor(mockCreditCardServer, mockTransactionProcessor);
when(mockCreditCardServer.isServerAvailable()).thenReturn(true);
when(mockTransactionProcessor.beginTransaction()).thenReturn(transaction);
when(mockCreditCardServer.initTransaction(transaction)).thenReturn(true);
when(mockCreditCardServer.pay(transaction, creditCard, 500))
.thenReturn(false);
when(mockTransactionProcessor.endTransaction()).thenReturn(true);
paymentProcessor.processPayment(creditCard, Money.dollars(500));
// pay()가 거래 내역을 실제로 전달했는지는 확인할 방법이 없다.
// 검증할 수 있는 건 pay()가 호출되었다는 사실뿐.
verify(mockCreditCardServer).pay(transaction, creditCard, 500);
}// 코드 13-14: 스텁을 걷어내고 가짜 객체/실제 구현으로 리팩터링
@Test public void creditCardIsCharged() {
paymentProcessor =
new PaymentProcessor(creditCardServer, transactionProcessor);
paymentProcessor.processPayment(creditCard, Money.dollars(500));
// 서버 상태를 조회해 지불 결과가 잘 반영됐는지 확인한다.
assertThat(creditCardServer.getMostRecentCharge(creditCard))
.isEqualTo(500);
}13-14가 훨씬 간결합니다. 트랜잭션 처리의 자세한 과정이 테스트 코드에서 사라졌고, 처리 방법은 신용카드 서버가 알고 있으니 특별히 설정할 게 없습니다. 외부 서버와 실제 통신하는 건 원치 않으니 신용카드 서버는 가짜 객체로 대체하는 게 좋습니다.
스텁을 쓰더라도 단정문과 직접 연관된 소수의 함수만 뭉개야 합니다. 스텁이 많이 눈에 띄는 것만으로도 과용 신호이거나, 대상 시스템이 너무 복잡하니 리팩터링하라는 신호일 수 있습니다.
상호작용 테스트(interaction test) — ‘어떻게’ 호출되는지 검증
상호작용 테스트(interaction test) 는 대상 함수를 실제로 호출하지 않으면서 그 함수가 ‘어떻게’ 호출되는지를 검증하는 기법입니다. 함수가 전혀 호출되지 않거나, 너무 많이 호출되거나, 잘못된 인수와 함께 호출되면 실패하게 만듭니다.
// 코드 13-9: 상호작용 테스트
AccessManager accessManager = new AccessManager(mockAuthorizationService);
accessManager.userHasAccess(USER_ID);
// userHasAccess(USER_ID)가 lookupUser(USER_ID)를 호출하지 않았다면 실패.
verify(mockAuthorizationService).lookupUser(USER_ID);상호작용 테스트의 근본 한계는 특정 함수가 호출되었는지만 알려줄 뿐, 올바르게 작동하는지는 말해주지 못한다는 점입니다. 그래서 어떤 구글 직원들은 이를 변경 검출 테스트(change-detector test) 라 부릅니다 — 행위는 그대로인데 코드를 변경만 하면 깨진다는 이유에서죠. 스텁과 마찬가지로 내부 구현을 노출하므로 깨지기 쉽습니다.
격리보다 현실성을 — 되도록 실제 구현을 써라
구글은 가능하다면 대상 시스템이 의존하는 실제 구현을 씁니다. 제품 코드가 쓰는 것과 똑같은 구현체를 써야 코드가 프로덕션과 동일하게 동작하고, 그만큼 테스트 충실성이 높아지기 때문입니다.
대역을 활용하는 테스트는 시스템을 의존 대상으로부터 격리시키므로, 아무리 실행해도 시스템이 실제 의존하는 코드는 실행되지 않습니다. 반대로 실제 구현을 쓰면 그 의존성에 버그가 있을 때 테스트가 실패합니다 — 좋은 일입니다. 프로덕션에서 제대로 동작하지 않을 것이라는 신호니까요.
이 흐름은 12장의 “상호작용이 아니라 상태를 테스트하라”와 한 몸입니다. 좋은 테스트는 구현이 어떻게 구성됐느냐가 아니라 검사할 API를 중심으로 작성되어야 합니다.
실제 구현을 선호하는 방식을 고전적 테스트(classical test), 모의 객체 프레임워크를 선호하는 방식을 모의 객체 중심주의 테스트(mockist test) 라 합니다. 구글은 모의 객체 중심주의가 확장하기 어렵다고 결론지었습니다 — 엔지니어가 대상 시스템을 엄격한 지침에 따라 설계해야 하기 때문입니다.
사례 연구 — @DoNotMock 애너테이션
구글은 모의 객체 의존이 지나쳐
@DoNotMock애너테이션까지 만들어야 했습니다. 정적 분석 도구 ErrorProne이 제공하는 기능으로, API 소유자가 ‘더 나은 대안이 있으니 이 타입은 모의 객체를 쓰지 말라’고 선언하는 용도입니다.왜 이렇게까지 할까요? 모의 객체를 쓰면 나중에 API를 변경하기가 더 어렵기 때문입니다. API 소유자가 API를 바꿨더니 코드베이스 전반에 수만 개의 모의 객체가 발견된 경우도 있습니다. 예컨대 null을 반환할 수 없게 고쳤지만 기존 모의 객체들은 여전히 null을 반환합니다. 모두가 실제 구현이나 가짜 객체를 썼다면 결함 있는 수천 개 테스트를 고치지 않고도 구현을 변경할 수 있었을 것입니다.
// 코드 13-10: @DoNotMock 애너테이션
@DoNotMock("모의 객체 대신 SimpleQuery.create()를 이용하세요.")
public abstract class Query {
public abstract String getQueryValue();
}실제 구현을 쓸지 결정하는 네 가지 잣대
실제 구현이 항상 답은 아닙니다. 빠르고 결정적이고 의존성 구조가 간단하다면 실제 구현을 쓰는 게 좋지만, 그렇지 않다면 절충해야 합니다.
| 잣대 | 실제 구현이 유리 | 대역이 유리 |
|---|---|---|
| 값 객체(value object) | 금액·날짜·주소·컬렉션 등은 거의 항상 실제 구현 | — |
| 실행 시간 | 단위 테스트당 수 ms 정도 느려짐 | 10ms·100ms·1초씩 느려지고 테스트 수가 많을 때(예: 500개) |
| 의존성 생성 | 의존성 트리가 얕고 단순할 때 | new Foo(new A(new B(...)), ...)처럼 트리가 깊어 생성·유지보수가 지옥일 때 |
| 결정성 | 같은 버전이면 항상 같은 결과를 낼 때 | 외부 서비스·시스템 클록·멀티스레드 순서에 의존해 결과가 튈 때 |
세 가지 보충 설명입니다.
- 실행 시간에는 정답이 없습니다. 느려지는 정도가 생산성을 얼마나 떨어뜨리는지는 엔지니어마다 다르게 느끼고 실제 구현을 쓰는 테스트 개수에 따라 다릅니다. 애매한 수준이면 그냥 실제 구현을 쓰고, 너무 느려졌다 싶을 때 대역을 투입하면 됩니다. 테스트 병렬화도 실행 시간을 줄이는 효과적 수단입니다.
- 의존성 생성이 부담입니다. 실제 구현을 쓰려면 의존성 트리에 등장하는 모든 객체를 생성해야 합니다. 객체 하나의 생성자 시그니처만 바뀌어도 테스트까지 함께 고쳐야 합니다. 반면 대역은 다른 객체를 별로 쓰지 않아 생성이 훨씬 쉽습니다(
@Mock Foo mockFoo;한 줄). - 결정성이 충실성과 충돌할 수 있습니다. 실제 구현은 대역보다 복잡해 비결정적일 여지가 더 많습니다(멀티스레드, 시스템 클록, 외부 서비스). 결과가 자주 튄다면 오히려 대역이 충실성을 높여줄 수 있습니다. 단, 실제 구현에 진짜 버그가 있어 튀는 거라면 그건 잡아야 할 문제이지 대역으로 가릴 게 아닙니다.
상호작용 테스트보다 상태 테스트를 — 13.8 모범 사례
상호작용 테스트가 필요한 경우도 있지만, 과용하면 깨지기 쉬워지므로 되도록 상태 테스트(state test) 를 우선합니다. 상태 테스트는 대상 시스템을 호출해 올바른 값을 반환하는지, 혹은 상태가 올바르게 변경됐는지를 검증합니다.
// 코드 13-15: 상태 테스트 — 결과(정렬된 리스트)만 검증. 알고리즘은 무관.
@Test public void sortNumbers() {
NumberSorter numberSorter = new NumberSorter(quicksort, bubbleSort);
List sortedList = numberSorter.sortNumbers(newList(3, 1, 2));
assertThat(sortedList).isEqualTo(newList(1, 2, 3));
}// 코드 13-16: 상호작용 테스트 — '퀵소트를 호출했다'는 사실만 검증
@Test public void sortNumbers_quicksortIsUsed() {
NumberSorter numberSorter =
new NumberSorter(mockQuicksort, mockBubbleSort);
numberSorter.sortNumbers(newList(3, 1, 2));
// mockQuicksort.sort()가 호출 안 되거나 잘못된 인수로 호출되면 실패.
verify(mockQuicksort).sort(newList(3, 1, 2));
}13-16은 반환값이 정렬됐는지 확인할 방법이 없습니다. 검증 가능한 건 ‘정렬을 시도했다’는 사실뿐입니다. 상태 테스트에 집중해야 훗날 제품과 테스트를 확장할 때 훨씬 유리하다는 게 구글의 결론입니다.
상호작용 테스트가 적합한 경우
물론 상호작용 테스트가 필요한 경우가 있습니다.
- 상태 테스트가 불가능할 때 — 실제 구현은 너무 느리고 가짜 객체는 존재하지 않아서 둘 다 쓸 수 없는 경우. 대비책으로 특정 함수 호출 여부를 검증해 어느 정도의 확신은 얻습니다.
- 호출 횟수·순서가 중요할 때 — 함수 호출 횟수나 순서가 달라지면 기대와 다르게 동작하는 경우. 예컨대 DB 호출 횟수를 줄이는 캐시 기능을 검증하려면 DB가 특정 횟수 이하로 호출되는지 확인합니다(
verify(databaseReader, atMostOnce()).selectRecords()).
단, 상호작용 테스트는 상태 테스트를 완전히 대체하지 못합니다. 단위 테스트에서 상태 테스트가 불가능하다면, 더 큰 범위의 테스트 스위트(통합 테스트)에서 실제 DB를 대상으로 상태 테스트를 수행해 보완하는 게 좋습니다.
상태 변경 함수에만 상호작용 테스트를 쓰자
시스템이 의존 객체의 함수를 호출하면 둘 중 하나입니다.
| 분류 | 정의 | 예 | 상호작용 테스트 |
|---|---|---|---|
| 상태 변경(state-mutating) | 바깥세상에 부수효과를 남김 | sendEmail(), saveRecord(), logAccess() | 적합 |
| 상태 유지(non-state-mutating) | 정보를 반환하지만 바꾸는 건 없음 | getUser(), findResults(), readFile() | 부적합 (중복) |
상태 유지 함수는 반환값을 다른 작업에서 어차피 확인하게 되므로, 그 상호작용까지 검증하면 다른 테스트와 중복됩니다. 게다가 단정문이 늘어 어느 단정문이 정확성과 관련된 것인지 구분하기 어려워집니다.
// 코드 13-17: 상태 변경 vs 상태 유지 — getPermission verify는 불필요
@Test public void grantUserPermission() {
UserAuthorizer userAuthorizer =
new UserAuthorizer(mockUserService, mockPermissionDatabase);
when(mockPermissionService.getPermission(FAKE_USER)).thenReturn(EMPTY);
userAuthorizer.grantPermission(USER_ACCESS);
// addPermission()은 상태 변경 함수 → 호출 여부 검증이 적합
verify(mockPermissionDatabase).addPermission(FAKE_USER, USER_ACCESS);
// getPermission()은 상태 유지 함수 → 다음 줄은 불필요.
// 4번째 줄에서 getPermission()을 스텁으로 대체했다는 게,
// 애초에 상호작용 테스트가 필요 없을 거라는 단서다.
verify(mockPermissionDatabase).getPermission(FAKE_USER);
}너무 상세한 테스트는 피하자
12장의 “메서드보다 행위를 테스트하라”는 원칙이 상호작용 테스트에도 적용됩니다. 어떤 함수가 어떤 인수로 호출되는지를 너무 세세하게 검증하지 않아야 테스트가 명확하고 간결해집니다.
// 코드 13-18: 과하게 상세한 상호작용 테스트 (나쁨)
@Test public void displayGreeting_renderUserName() {
when(mockUserService.getUserName()).thenReturn("Fake User");
userGreeter.displayGreeting();
// setText()에 건네는 인수 중 하나라도 바뀌면 테스트가 실패한다.
verify(userPrompt).setText("Fake User", "Good morning!", "Version 2.1");
// setIcon()은 이 테스트와 관련 없는 부수 동작인데도 검증한다.
verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}이 테스트의 목적은 ‘환영 프롬프트에 사용자 이름이 들어 있는지’인데, 무관한 행위(인사말 문구·버전·아이콘)가 달라져도 실패합니다. 검증하려는 행위들을 각각의 테스트로 나누고, 각 테스트는 꼭 필요한 정보만 확인해야 합니다.
// 코드 13-19: 적당하게 상세한 상호작용 테스트 (좋음)
@Test public void displayGreeting_renderUserName() {
when(mockUserService.getUserName()).thenReturn("Fake User");
userGreeter.displayGreeting();
verify(userPrompter).setText(eq("Fake User"), any(), any());
}
@Test public void displayGreeting_timeIsMorning_useMorningSettings() {
setTimeOfDay(TIME_MORNING);
userGreeter.displayGreeting();
verify(userPrompt).setText(any(), eq("Good morning!"), any());
verify(userPrompt).setIcon(IMAGE_SUNSHINE);
}eq()로 관심 있는 인수만 못박고 나머지는 any()로 풀어줍니다. 이렇게 하면 각 테스트의 범위를 벗어난 행위가 바뀌어도 깨지지 않습니다.
비교 / 트레이드오프
세 기법 한눈에 — fake vs stub vs interaction
| 관점 | 가짜 객체(fake) | 스텁(stub) | 상호작용 테스트 |
|---|---|---|---|
| 본질 | 실제와 비슷하게 동작하는 가벼운 구현 | 함수 반환값을 테스트가 지정 | 호출 여부·방식을 검증 |
| 상태 | 내부 상태를 가짐 | 없음 (뭉개기만) | 없음 |
| 충실성 | 높음 (실제와 동일하게 시뮬레이션) | 낮음 (명세를 하드코딩) | 낮음 |
| 깨지기 쉬움 | 낮음 | 높음 (내부 구현 노출) | 높음 (변경 검출 테스트) |
| 작성 비용 | 큼 (실제 팀이 관리) | 작음 | 작음 |
| 주 용도 | 실제 구현이 느릴 때의 1순위 대체 | 특정 반환값/오류를 강제로 만들 때 | 상태 테스트가 불가능하거나 호출 횟수가 중요할 때 |
우선순위 사다리
실제 구현 → 가짜 객체 → 스텁/상호작용 테스트 순으로 내려갑니다. 위로 갈수록 충실하지만 느리거나 만들기 어렵고, 아래로 갈수록 빠르고 쉽지만 깨지기 쉽고 효과가 떨어집니다. 12장의 상태 테스트 선호와 같은 정신입니다 — 구현이 아니라 행위·상태를 검증하라.
내 생각
-
Mockito
when().thenReturn()떡칠은 코드 13-13 그 자체입니다. 서비스 테스트에서 의존 빈을 전부 목으로 세우고 호출 흐름을 줄줄이 스텁하면, 그건 SUT를 테스트하는 게 아니라 내가 짠 호출 순서를 복창하는 꼴입니다. 인메모리 fake 레포지토리로 “저장 후 조회되는가”를 상태로 보는 편이 리팩터링에 훨씬 강합니다. -
@DoNotMock사례가 ‘API 변경 비용’을 정확히 짚습니다. 목이 수만 개 박히면 null 정책 하나 바꾸는 데 테스트 수천 개가 따라 깨집니다. 공유 라이브러리·도메인 타입일수록 목을 막고 가짜 객체나 빌더 fixture를 제공하는 게 장기 비용을 줄입니다. -
상태 변경 vs 상태 유지 구분이 verify 남용의 실전 필터입니다.
getXxx()를 verify하고 있다면 십중팔구 불필요한 상호작용 테스트입니다. 반환값을 단정문에서 이미 쓰고 있으니까요. verify는save·send·publish같은 부수효과 함수에만 답니다. -
eq()+any()조합이 ‘적당히 상세하게’의 구체적 도구입니다. 검증할 인수 하나만 못박고 나머지는 풀어주면, 무관한 파라미터가 늘어나도 테스트가 안 깨집니다. given/when/then 하나당 관심사 하나라는 12장 원칙과 짝을 이룹니다.
관련 개념
- Ch12 단위 테스트 — ‘상태를 테스트하라’, ‘모의 객체 과용 경고’, DAMP. 이 장은 그 원칙을 대역 선택 기준으로 구체화합니다
- Ch11 테스트 개요 — 작은 테스트 vs 큰 테스트. 대역으로 못 채우는 충실성은 더 큰 범위의 테스트로 보완한다는 흐름의 토대