Code/Test

[단위 테스트] 5-1. 목과 테스트 취약성: 목

noahkim_ 2025. 1. 24. 03:56

블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.


1. Mock vs Stub

  • 테스트 대상 시스템과 그 협력자 사이의 상호 작용을 검사할 수 있는 테스트 대역
구분 Mock (목) Stub (스텁)
역할 외부로 나가는 상호 작용을 모방하고 검증
내부로 들어오는 데이터/행위를 모방
목적 SUT가 의존 대상에 정확히 호출했는지 검증
SUT가 의존 대상에서 입력 데이터를 제공받도록 구성
방향 Out-bound (외부로 나가는 호출)
In-bound (내부로 들어오는 호출)
검증 여부 ✅ 상호작용 검증 (호출 횟수, 순서, 인자 등) 
❌ 호출 여부나 인자 등 검증하지 않음
예시 이메일 전송 여부, 결제 API 호출 여부 등
DB에서 회원 조회 결과 반환, 외부 API에서 응답 반환 등
적합한 상황 부작용 발생 확인 (ex. 알림, 로그 저장, 외부 API)
데이터 주입 (ex. 사용자 정보, 설정 값, 통신 결과)
주의사항 과도한 상호작용 검증은 테스트 유지보수성을 해칠 수 있음
스텁에 대한 상호작용 검증은 하지 말 것
테스트 위험 과도한 목 검증 → 과잉 명세
스텁 호출 검증 시 → 구현 세부사항 노출로 테스트 취약

 

CQS 원칙

  • Command Query Separation
  • 모든 메서드는 명령이거나 조회여야 함
구분 Command (명령) Query (조회)
의미 상태를 변경하는 연산
상태를 조회하는 연산
특징 부작용  
반환값 (void 또는 무시됨)
부작용  
반환값  
예시 사용자 등록, 이메일 전송, 로그 기록
사용자 조회, 설정 값 읽기, 상태 확인
테스트 대역 목(Mock): 호출 여부 및 상호작용 검증
스텁(Stub): 고정된 데이터 반환
검증 대상 호출의 사이드 이펙트(부작용)
반환되는 결과 데이터
테스트 시나리오
“이 메서드가 외부에 잘 전달되었는가?”
“이 메서드가 기대한 데이터를 반환하는가?”

 

예시) Command

더보기
public class UserService {
    private final EmailService emailService;

    public UserService(EmailService emailService) {
        this.emailService = emailService;
    }

    public void registerUser(User user) {
        // 유저 등록 로직 (예: DB 저장)
        // ...

        // 이메일 전송 (부작용)
        emailService.sendWelcomeEmail(user.getEmail());
    }
}
@Test
void registerUser_sendsWelcomeEmail() {
    EmailService emailService = mock(EmailService.class);
    UserService userService = new UserService(emailService);
    User user = new User("noah@example.com");

    userService.registerUser(user);

    // sendWelcomeEmail 호출 여부 검증
    verify(emailService).sendWelcomeEmail("noah@example.com");
}

 

예시) Query

더보기
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUser(String email) {
        return userRepository.findByEmail(email);  // 조회 (부작용 없음)
    }
}
@Test
void getUser_returnsUser() {
    UserRepository userRepository = mock(UserRepository.class);
    UserService userService = new UserService(userRepository);
    User expectedUser = new User("noah@example.com");

    when(userRepository.findByEmail("noah@example.com")).thenReturn(expectedUser);

    User result = userService.getUser("noah@example.com");

    assertEquals(expectedUser, result);
}

 

2. 식별할 수 있는 동작과 구현 세부 사항

식별할 수 있는 동작은 공개 API와 다르다

  • 공개 API는 식별할 수 있는 동작을 제공하여, 외부에서 쉽게 사용할 수 있도록 설계되어야 함
  • 비공개 API는 외부와의 상호작용에서 내부 동작을 독립적으로 처리할 수 있도록 설계되어야 함

 

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

구분 식별할 수 있는 동작 구현 세부 사항
클라이언트 관점 무엇을 할 수 있는가?
어떻게 작동하는가?
API 목적 사용자의 목표 달성 내부 작동 방식
위험 요소 없음 캡슐화 깨짐

  

구현 세부 사항 유출

구분 설명 문제점
연산
단일한 목표를 위해 여러 개의 메서드를 호출해야 하는 경우 캡슐화 깨짐
- 클라이언트가 구현의 흐름을 알아야 함
불변성 위반 가능성
- 여러 연산이 복잡하게 얽혀 상태 변경이 명확히 드러나지 않음
상태
유의미한 동작에 관련된 상태 값을 외부에 노출하는 경우
캡슐화 깨짐
- 행위와 역할이 분리 (클라이언트가 상태를 보고 직접 처리해야 함)
시스템 일관성 깨질 위험
- 내부 로직이 외부에 의존
- 외부 코드 변경 시, 예상했던 동작과 다르게 동작함

 

 예시) 연산 유출

더보기

// Client가 여러 메서드를 조합해야 한다 (불변성 깨질 수 있음)
User user = new User();
user.validateEmail(email);
user.saveToDatabase();
user.sendWelcomeEmail();

 

// 내부적으로 순서와 불변성을 관리
UserService userService = new UserService();
userService.registerUser(email);

 

예시) 상태 유출

더보기

// balance를 노출하고 클라이언트가 판단
Account account = accountRepository.findById(id);
if (account.getBalance() >= amount) {
    account.withdraw(amount);
}

 

// 의미 있는 행동만 제공
Account account = accountRepository.findById(id);
if (account.canWithdraw(amount)) {
    account.withdraw(amount);
}