목과 테스트 취약성

개요

Mock 객체는 단위 테스트에서 중요한 도구이지만, 그 사용 방식에 따라 테스트의 리팩터링 내성을 저하시킬 수 있다. 이 글에서는 목의 정의와 역할, 스텁과의 차이, 잘못된 사용이 초래하는 테스트 취약성, 그리고 올바른 사용 원칙을 정리한다. 또한 런던파와 고전파 테스트 철학을 비교하고, 육각형 아키텍처와 테스트 설계 간의 관계도 함께 설명한다.

개념 설명

런던파 vs 고전파

  • 런던파(London School): 모든 의존성을 테스트 대역(mock/stub)으로 대체하며, 테스트 대상 객체를 철저히 격리한다. 단위는 클래스 단위이며, 의존성 주입과 목을 통해 상호작용을 검증한다.
  • 고전파(Classic School): 테스트 간 공유되는 의존성만 테스트 대역으로 대체한다. 시스템의 실제 상호작용을 중요시하고, 클래스 단위보다는 유즈케이스 단위의 테스트를 선호한다.

테스트 대역의 분류

테스트 대역(test double)은 실제 의존성 대신 테스트용으로 사용하는 객체를 통칭하는 개념이다. 제라드 메스자로스(Gerard Meszaros)는 테스트 대역을 다음 다섯 가지로 분류했다. 각 개념은 목적과 사용 방식이 조금씩 다르며, 적절하게 선택해서 사용하는 것이 중요하다.

더미(Dummy)

  • 더미(dummy)는 null 이나 고정된 값을 제공하는 아주 단순한 객체다. 테스트 결과에는 영향을 주지 않으며, 단지 메서드 시그니처나 인자 전달용으로 사용된다.
  • 예를 들어, 로깅 인터페이스를 생성자에서 요구하지만 테스트에서 로그가 필요하지 않은 경우, 아무 동작도 하지 않는 Logger 구현체를 전달할 수 있다.
  • 쉽게 말하면, 테스트를 통과시키기 위해 형식상 채워 넣는 가짜 객체다.
Java
public class DummyLogger implements Logger {
  @Override
  public void log(String message) {
    // 아무 동작도 하지 않음
  }
}

스텁(Stub)

  • 스텁(stub)은 메서드 호출에 대해 미리 정의된 값을 반환하도록 설정된 객체다. 외부 시스템 없이도 원하는 값을 반환해 테스트를 가능하게 해준다.
  • 예를 들어, 현재 시간을 반환하는 객체를 테스트에서 고정된 시간으로 반환하게 만들어 특정 로직을 검증할 수 있다.
  • 입력에 따라 고정된 응답을 흉내내는 객체라고 생각하면 된다.
Java
public class StubClock implements Clock {
  @Override
  public LocalDateTime now() {
    return LocalDateTime.of(2025, 1, 1, 0, 0);
  }
}

스파이(Spy)

  • 스파이(spy)는 메서드 호출 여부, 인자 값, 호출 횟수 등을 기록하는 테스트 대역이다. 동작은 스텁처럼 동작하면서도 나중에 어떤 일이 있었는지 검증할 수 있다.
  • 예를 들어, 이메일을 발송하는 서비스를 흉내 내면서 실제 발송 여부와 횟수를 검증할 수 있다.
  • 누가 몇 번 어떻게 불렀는지 엿보는 객체다.
Java
public class EmailSenderSpy implements EmailSender {
  private int sendCount = 0;

  @Override
  public void send(String to, String content) {
    sendCount++;
  }

  public int getSendCount() {
    return sendCount;
  }
}

목(Mock)

  • 목(mock)은 예상되는 상호작용을 사전에 정의하고, 실제로 그 상호작용이 일어났는지를 검증하는 객체다. 테스트 프레임워크(예: Mockito)의 도움을 받아 동작한다.
  • 예를 들어, 결제 API가 특정 파라미터로 호출되었는지 확인할 수 있다.
  • 이렇게 불려야 해! 라는 시나리오를 검증하는 객체다.
Java
PaymentGateway gateway = mock(PaymentGateway.class);

// 테스트 대상 실행
verify(gateway).

pay(eq("user123"),anyInt());

페이크(Fake)

  • 페이크(fake)는 실제 구현체와 유사하게 동작하지만, 단순화된 버전이다. 실제 시스템을 흉내 낸 미니 버전으로, 일반적으로 인메모리 구현체가 많다.
  • 예를 들어, 데이터베이스 대신에 Map을 사용한 인메모리 저장소가 페이크에 해당한다.
  • 진짜처럼 보이지만 간소화된 임시 버전의 객체다.
Java
public class InMemoryUserRepository implements UserRepository {
  private Map<String, User> store = new HashMap<>();

  @Override
  public void save(User user) {
    store.put(user.getId(), user);
  }

  @Override
  public User findById(String id) {
    return store.get(id);
  }
}

목과 스텁의 차이

구분목(Mock)스텁(Stub)
역할호출 여부 및 순서 검증예상 값 반환
검증 대상외부로 나가는 상호작용내부로 들어오는 상호작용
사용 목적행위(동작) 검증상태 기반 결과 유도
테스트 결과 영향검증 실패 시 테스트 실패결과만 제공, 검증 대상 아님
  • 중요: 스텁에 대한 상호작용 검증은 테스트의 리팩터링 내성을 해친다. 이를 과잉 명세(Overspecification)라고 부른다.

명령-조회 분리(CQS)와 목/스텁

  • 명령(Command): 사이드 이펙트를 유발하며 값을 반환하지 않음 → 대상
  • 조회(Query): 상태만 반환하며 사이드 이펙트 없음 → 스텁 대상

구현 세부 사항과 테스트 취약성

식별할 수 있는 동작 vs 구현 세부 사항

  • 식별할 수 있는 동작: 외부에서 관찰 가능한 결과 (예: 반환 값, 상태 변화, 외부 시스템과의 상호작용)
  • 구현 세부 사항: 호출 순서, 내부 메서드 호출, 클래스 간 협력 구조 등

테스트는 무엇을 하는가에 초점을 맞춰야지, 어떻게 하는가를 검증하면 안 된다.

식별할 수 있는 동작구현 세부 사항
공개 API좋음나쁨
비공개 API해당 없음좋음

캡슐화와 리팩터링 내성

  • 캡슐화를 통해 내부 구현 세부 사항을 숨기면, 테스트는 외부에서 식별할 수 있는 동작만을 검증하게 된다.
  • 이는 테스트와 코드 간 결합도를 낮추고 리팩터링 내성을 높이는 핵심 전략이다.

육각형 아키텍처와 테스트

  • 육각형 아키텍처도메인 계층, 애플리케이션 서비스 계층, 외부 인터페이스로 구성된다.
  • 도메인 계층은 외부와 분리되어야 하며, 외부 통신은 서비스 계층을 통해 이루어져야 한다.
  • 테스트는 이 아키텍처 구조에 따라 관심사를 분리한 채로 설계되어야 한다.

시스템 내부 vs 시스템 간 통신

통신 유형설명테스트 목적
시스템 내부 통신클래스 간 협력구현 세부 사항 (테스트 X)
시스템 간 통신외부 API, DB 등식별 가능한 동작 (테스트 O)
  • 목은 시스템 간 통신 검증에는 유용하지만, 내부 구현에 사용되면 테스트 취약성으로 이어진다.

테스트 격리와 의존성 유형

  • 공유 의존성: 테스트 간 공유 (e.g., DB) → 스텁 또는 페이크
  • 프로세스 외부 의존성: 외부 시스템 (e.g., SMTP 서버) → 필요 시 목
  • 비공개 의존성: 단일 테스트에서만 사용 → 실제 객체 사용

정리

  • 목은 잘 사용하면 테스트 설계에 유용하지만, 내부 구현에 결합되면 테스트를 깨지기 쉬운 구조로 만든다.
  • 스텁과의 상호작용 검증은 피해야 하며, 검증은 항상 최종 결과를 기준으로 해야 한다.
  • 테스트는 구현이 아닌 동작을 대상으로 삼아야 한다.
  • 육각형 아키텍처나 CQS 같은 설계 원칙은 테스트의 리팩터링 내성을 높이기 위한 강력한 기반이 된다.

용어 정의

목(mock)

SUT가 외부에 의존해 호출하는 메서드에 대한 호출 여부나 인자를 검증할 수 있는 테스트 대역이다. 주로 사이드 이펙트를 발생시키는 명령을 검증한다.

스텁(stub)

SUT가 입력 데이터를 얻기 위해 호출하는 의존성을 흉내내는 테스트 대역이다. 상태 기반 결과를 반환하며, 검증이 아닌 흉내만 낸다.

테스트 취약성(test fragility)

테스트가 코드 리팩터링에 민감하게 반응해 자주 깨지는 성질이다. 구현 세부 사항을 검증하는 테스트에서 자주 발생한다.

식별할 수 있는 동작

외부에서 관찰 가능한 최종 결과로, 반환 값이나 상태 변화, 외부와의 통신 등이 포함된다. 테스트는 이를 대상으로 해야 한다.

CQS (Command Query Separation)

모든 메서드를 명령(사이드 이펙트) 또는 조회(값 반환) 중 하나로 구분해야 한다는 원칙이다. 테스트에서도 이 구분을 기준으로 목과 스텁을 적절히 나누어 사용해야 한다.

육각형 아키텍처

도메인 로직과 외부 인터페이스를 애플리케이션 서비스 계층을 통해 명확히 분리한 아키텍처이다. 테스트와 구현 간 결합을 줄이고 변경에 유연하게 대처할 수 있다.