나만 모르는 Mock과 단위 테스트 정리

Bien·2025년 9월 28일

면접에서 테스트에 대해 진득하게 털리고 차근차근 공부하는 테스트🥲 다음엔 털리지 말자

1.Mock이란 무엇인가?

Mock의 정의

  • Mock: 실제 객체를 대신하는 "가짜 객체"
  • 실제 외부 의존성(DB, API 등) 없이 테스트할 수 있게 해주는 도구
  • 테스트에서 "외부 협력자"의 동작을 미리 정의해서 사용

기본 코드 예시

@ExtendWith(MockitoExtension.class)
public class PointServiceTest {
    @InjectMocks PointService pointService; // Mock을 주입받은 테스트 대상
    @Mock PointAccountRepository pointAccountRepository; // 가짜 Repository

    @Test
    void 신규계정_잔액은_0원(){
        //given 
        String userId = "user1";
        given(pointAccountRepository.save(any()))
                .willAnswer(invocation -> invocation.getArgument(0)); //"save() 메서드가 호출되면, 첫 번째 인자(0번 인덱스)를 그대로 반환해줘"
        
        //when
        var account = pointService.createAccount(userId);
        
        //then
        assertThat(account.getBalance()).isZero();
    }
}

2.Mock을 사용하는 이유

실제 객체 사용 시 문제점

// Mock 없는 테스트 
@Test
void 실제객체_사용() {
    // 실제 DB 연결이 필요함
    PointAccountRepository realRepository = new JpaPointAccountRepository();
    PointService pointService = new PointService(realRepository);
    
    var acc = pointService.createAccount("user1"); // 실제 DB 접근
}

발생하는 문제들:

  • 속도 저하 : DB연결, 쿼리 실행 시간 소요
  • 환경 의존성 : DB서버가 켜져있어야 함
  • 테스트 간섭: 다른 테스트의 DB 상태에 영향받음
  • 복잡한 설정: 테스트용 DB 환경 구축 필요
  • 불안정성: 네트워크 장애 시 테스트 실패

Mock 사용 시 장점

  • 빠른 속도: 메모리에서만 동작 (1-2ms)
  • 독립성: 다른 테스트나 외부 환경과 무관
  • 안정성: DB나 네트워크 장애와 무관
  • 단순함: 복잡한 외부 설정 불필요
  • 집중: 테스트하고 싶은 로직에만 집중 가능

3. Mock 동작 원리 이해

객체 생성 vs 메서드 호출

내가 계속 혼동하는 부분:

// 1. 객체 생성 - DB 연결 하지 않음
PointAccountRepository realRepository = new JpaPointAccountRepository();
// ↑ 이 시점에는 DB 연결 없음. 그냥 객체만 생성

// 2. 메서드 호출 - 여기서 DB 연결!
realRepository.save(account); 
// ↑ 이 시점에서 실제 DB에 INSERT 쿼리 실행

Mock vs Real 동작 비교

// Real Repository 동작
pointService.createAccount("user1") {
    1. new PointAccount("user1") 생성
    2. repository.save() 호출
       → DB 연결
       → INSERT 쿼리 실행  
       → 네트워크 통신
       → 트랜잭션 처리
       → 결과 반환 (느림, 복잡함)
}

// Mock Repository 동작  
pointService.createAccount("user1") {
    
    // 1단계: PointAccount 객체 생성
    PointAccount account = new PointAccount("user1", 0L);
    // 메모리에 실제 객체 생성됨
    // account = PointAccount { userId: "user1", balance: 0 }
    
    // 2단계: repository.save() 호출
    return pointAccountRepository.save(account);
    //                            ↑
    //                    이 account 객체를 인자로 넘김
    
    // 3단계: Mock이 willAnswer 규칙 적용
    // invocation.getArgument(0) = 넘어온 첫 번째 인자 = account 객체
    // 즉, 넘어온 account 객체를 그대로 반환
}

4.TDD에서 Mock 사용법

TDD의 올바른 순서

🔴 Red → 🟢 Green → 🔵 Blue(Refactor)

Step 1: Red - 실패하는 테스트 작성

@Test
void 신규계정_잔액은_0원(){
    //given
    String userId = "user1";
    
    //when
    var account = pointService.createAccount(userId); // 컴파일 에러!
    
    //then
    assertThat(account.getBalance()).isZero();
}

결과: PointService.createAccount() 메서드가 없거나 null을 반환해서 에러를 만든다. 지금 경우는 그냥 없음.

Step 2: Green - 최소한의 코드로 통과

public class PointService {
    public PointAccount createAccount(String userId) {
        return new PointAccount(userId, 0L); // 하드코딩으로 통과
    }
}

결과: 테스트 통과! (하지만 하드코딩)

Step 3: Blue - 리팩토링 (Mock 추가)

public class PointService {
    private final PointAccountRepository repository;
    
    public PointAccount createAccount(String userId) {
        PointAccount account = new PointAccount(userId, 0L);
        return repository.save(account); // Repository 패턴 적용
    }
}

// 테스트에 Mock 추가
@Test
void 신규계정_잔액은_0원(){
    //given
    String userId = "user1";
    given(pointAccountRepository.save(any()))
        .willAnswer(invocation -> invocation.getArgument(0));
    
    //when
    var account = pointService.createAccount(userId);
    
    //then
    assertThat(account.getBalance()).isZero();
    assertThat(account.getUserId()).isEqualTo(userId);
}

Step 3: Blue - 리팩토링 (Mock 추가)

public class PointService {
    private final PointAccountRepository repository;
    
    public PointAccount createAccount(String userId) {
        PointAccount account = new PointAccount(userId, 0L);
        return repository.save(account); // Repository 패턴 적용
    }
}

// 테스트에 Mock 추가
public class PointService {
    private final PointAccountRepository repository;
    
    public PointAccount createAccount(String userId) {
        PointAccount account = new PointAccount(userId, 0L);
        return repository.save(account); // Repository 패턴 적용
    }
}

// 테스트에 Mock 추가
@Test
void 신규계정_잔액은_0원(){
    //given
    String userId = "user1";
    given(pointAccountRepository.save(any()))
        .willAnswer(invocation -> invocation.getArgument(0));
    
    //when
    var account = pointService.createAccount(userId);
    
    //then
    assertThat(account.getBalance()).isZero();
    assertThat(account.getUserId()).isEqualTo(userId);
}

5.Mock 설정이 없으면 어떻게 될까?

잘못된 예시

@Test
void Mock설정_없는_잘못된_테스트(){
    //given
    String userId = "user1";
    // ← Mock 설정이 없음!
    
    //when
    var account = pointService.createAccount(userId); 
    // ↑ pointAccountRepository.save()가 null 반환!
    
    //then
    assertThat(account.getBalance()).isZero(); // NullPointerException 발생!
}

6. Given-When-Then 구조에서 Mock의 역할

Given 부분에서 Mock 설정의 의미

//given
String userId = "user1"; // ← 실제 테스트 데이터 준비
given(pointAccountRepository.save(any())) // ← Mock 동작 정의 (인프라 준비)
        .willAnswer(invocation -> invocation.getArgument(0));

구분해서 이해해보자:

  • 테스트 데이터: String userId = "user1"
  • Mock 설정: given().willAnswer() - 외부 의존성의 가짜 동작 정의

더 명확한 분리 방법

@BeforeEach
void setUp() {
    // Mock 동작을 여기서 미리 설정 (인프라 준비)
    given(pointAccountRepository.save(any(PointAccount.class)))
            .willAnswer(invocation -> invocation.getArgument(0));
}

@Test
void 신규계정_잔액은_0원(){
    //given - 순수한 테스트 데이터만
    String userId = "user1";
    
    //when
    var account = pointService.createAccount(userId);
    
    //then
    assertThat(account.getBalance()).isZero();
    assertThat(account.getUserId()).isEqualTo(userId);
    
    // 추가 검증: repository의 save가 실제로 호출되었는지
    verify(pointAccountRepository).save(any(PointAccount.class));
}

7.핵심 개념 정리

관심사의 분리

@Test 
void 신규계정_잔액은_0원(){
    // 우리의 관심사: "PointService가 새 계정을 올바르게 생성하는가?"
    // 관심 없는 것: "Repository가 DB에 잘 저장하는가?" 
    // ↑ 이건 Repository 테스트에서 별도로 검증!
    
    var acc = pointService.createAccount("user1");
    assertThat(acc.getBalance()).isZero(); // 이것만 검증하고 싶음
}

Mock의 핵심 철학

내가 테스트하고 싶은 로직에만 집중할 수 있게 해준다

  • PointService의 로직 테스트 ← 여기에 집중
  • Repository의 DB 저장 로직 ← 별도 테스트에서 검증

8.단위 테스트 철학 논쟁: Detroit vs London

이게 면접에서 나왔고 테스트는 mock을 사용한 테스트 밖에 모른다고 멍청한 대답을 당당히 하고 나왔다...🤦🏻‍♀️

두 파벌의 핵심 주장

🏭 Detroit School (Classicist) - "Mock은 가짜 테스트다!"

핵심 철학: "실제 객체들이 상호작용해야 의미 있는 테스트"
주요 주장:

  • Mock으로 하는 테스트는 진짜 테스트가 아니다
  • 실제 객체들의 협력을 검증해야 한다
  • Mock 설정이 틀리면 거짓 안전감만 준다
  • 리팩토링에 강하다 (내부 구현 변경에 덜 민감)
// Detroit School 방식
@Test
void detroit_방식() {
    Calculator realCalc = new Calculator();        // 실제 객체
    TaxService realTax = new TaxService();         // 실제 객체
    OrderService order = new OrderService(realCalc, realTax);
    
    // 실제 메서드들이 실제로 호출되고 계산됨
    assertEquals(110, order.calculateTotal(100));
}

🏢 London School (Mockist) - "Mock이 진짜 단위테스트다!"

핵심 철학: "의존성 있으면 단위테스트가 아니다"
주요 주장:

  • 격리가 완벽해야 진짜 단위테스트다
  • 협력 객체 문제 때문에 내 테스트가 깨지면 안 된다
  • 하나의 클래스만 테스트해야 한다
  • 빠르고 독립적이어야 한다
// London School 방식
@Test  
void london_방식() {
    Calculator mockCalc = mock(Calculator.class);
    TaxService mockTax = mock(TaxService.class);
    when(mockCalc.calculate(100)).thenReturn(100);
    when(mockTax.getTax(100)).thenReturn(10);
    
    OrderService order = new OrderService(mockCalc, mockTax);
    assertEquals(110, order.calculateTotal(100));  // 완벽히 격리된 테스트
}

각 방식의 장단점 비교

Detroit School (실제 객체)
장점:

  • 현실성: 실제 상호작용을 검증하므로 더 신뢰할 수 있음
  • 리팩토링 안전: 내부 구현 바뀌어도 테스트가 깨지지 않음
  • 간단함: Mock 설정 코드 없이 깔끔
  • 통합 검증: 여러 객체 간 협력도 함께 검증

단점:

  • 속도: 실제 계산/로직 수행하니까 상대적으로 느림
  • 복잡한 설정: 실제 객체들의 의존성도 다 준비해야 함
  • 테스트 범위 애매: 여러 클래스 함께 테스트하니까 단위테스트인지 애매
  • 디버깅 어려움: 어디서 실패했는지 파악하기 어려울 수 있음

London School (Mock)
장점:

  • 빠름: 실제 로직 수행 안 하니까 매우 빠름
  • 격리: 테스트 대상 클래스만 집중해서 테스트
  • 제어 가능: 원하는 시나리오 쉽게 만들 수 있음 (예외 상황 등)
  • 명확한 범위: 진짜 "단위"테스트

단점:

  • 가짜 안전감: Mock이 잘못되면 실제로는 안 되는데 테스트는 통과
  • 구현 의존적: 내부 구현 바뀌면 Mock 설정도 다 바껴야 함
  • 복잡한 Mock 설정: when().thenReturn() 코드가 많아질 수 있음
  • Mock 동기화: 실제 구현과 Mock 동작이 달라질 위험

9. 실무에서 언제 어떤 방식을 쓸까?

Mock을 써야 하는 경우

  1. 외부 시스템 의존성
void 이메일발송_테스트(){
	// 실제 이메일 서버에 보낼 수 없으니까 Mock 필수
    EmailService mockEmail = mock(EmailService.class);
    UserService service = new UserService(mockEmail);
    
    service.registerUser("test@test.com");
    
    verify(mockEmail).send("test@test.com", "Welcome!");	
}
  1. 느린 작업 (DB, 네트워크, 파일 I/O)
@Test
void 데이터베이스_저장_테스트() {
    // 실제 DB 연결하면 너무 느려서 Mock 사용
    UserRepository mockRepo = mock(UserRepository.class);
    UserService service = new UserService(mockRepo);
    
    service.saveUser(new User("test"));
    
    verify(mockRepo).save(any(User.class));
}
  1. 예외 상황 테스트
@Test
void 네트워크_장애_테스트() {
    // 실제로 네트워크 장애 만들기 어려우니까 Mock으로 시뮬레이션
    ApiClient mockApi = mock(ApiClient.class);
    when(mockApi.call()).thenThrow(new NetworkException());
    
    PaymentService service = new PaymentService(mockApi);
    
    assertThrows(PaymentFailedException.class, 
        () -> service.processPayment(100));
}

실제 객체를 써야 하는 경우

  1. 값 객체 (Value Object)
@Test
void 금액_계산_테스트() {
    // Money, Price 같은 값 객체는 실제 객체 사용
    Money price = new Money(1000);
    Money tax = new Money(100);
    
    Money total = price.add(tax);
    
    assertThat(total.getAmount()).isEqualTo(1100);
}
  1. 단순한 계산 로직
@Test
void 계산기_테스트() {
    // 간단한 계산기는 실제 객체가 더 의미있음
    Calculator calc = new Calculator();
    MathService service = new MathService(calc);
    
    int result = service.calculate(10, 5);
    
    assertThat(result).isEqualTo(15);
}
  1. 도메인 로직의 협력
@Test
void 주문_비즈니스_로직_테스트() {
    // 핵심 도메인 로직은 실제 객체들의 협력이 중요
    PriceCalculator priceCalc = new PriceCalculator();
    DiscountPolicy discountPolicy = new VipDiscountPolicy();
    OrderService service = new OrderService(priceCalc, discountPolicy);
    
    Order order = service.createOrder("VIP고객", 10000);
    
    assertThat(order.getFinalPrice()).isEqualTo(9000); // 10% 할인
}

결론: 적절히 같이 쓰자 🫶🏻

profile
🙀

0개의 댓글