본문 바로가기

개발/루퍼스(Loopers)

복잡한 테스트를 단순하게, 테스트 더블로 풀어본 경험

반응형

나는 이번에 회원가입과 포인트 적립 기능을 개발하면서 TDD 원칙을 도입하였다

 

단위 테스트(Unit), 통합 테스트(Integration), E2E(End-to-End)와 같이 다양한 테스트들을 작성하다보니 테스트 구조가 복잡해져, 테스트가 터졌을 때 비즈니스 로직이 잘못 된건지, 응답이 잘못된건지 정확한 지점을 찾기 힘들었다. 

 

코드를 작성하는 것보다 테스트하는 것이 중요해진 요즘, 내가 테스트 더블을 통해 그 것들을 해결한 방법을 남겨놓기로 하였다.


꼬리에 꼬리를 무는 컨트롤러 테스트

회원가입 컨트롤러는 아래와 같은 구조였다.

@RestController
@RequestMapping("/user")
public class UserController {
    private final UserService userService;
    ...
}

 

나는 비즈니스 로직 검증이 아닌  요청과 응답의 흐름을 확인하고 싶었으나, UserController는 UserService에 완전히 의존하고 있었기에 컨트롤러를 단독으로 테스트를 수행할 수 없었다. 

 

나는 이를 해결하기 위해 @MockBean을 사용하여 가짜 UserService를 Spring Context에 주입하였다.


Mock을 활용해보자 

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService; // 실제 대신 Mock 주입

    @Test
    void signUp_returns_200_and_responseBody() throws Exception {
        UserResponseDto fakeResponse = new UserResponseDto(
            "sangdon", "dori@dori.com", "1998-02-21", "MALE"
        );

        when(userService.signUp(any())).thenReturn(fakeResponse);

        mockMvc.perform(post("/user/signUp")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"id":"sangdon","email":"dori@dori.com","birthDate":"1998-02-21","gender":"MALE"}
                """))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("sangdon"))
            .andExpect(jsonPath("$.email").value("dori@dori.com"));
    }
}

이렇게 작성하니 훨씬 간결해졌다.


1. DB나 JPA를 실행하지 않아도 테스트가 가능해졌고, 테스트 속도도 크게 향상되었다.

2. 테스트가 실패하더라도 원인을 명확히 파악할 수 있었다 (DB 문제인지, 컨트롤러 로직 문제인지 헷갈릴 일이 없었다)
3. 또한 서비스에서 발생하는 예외 상황(예: 존재하지 않는 회원)에 대해 컨트롤러가 404를 반환하는지 등 에러 매핑 로직도 손쉽게 검증할 수 있었다.

 


Spy로 중복 회원 가입 막기 

또한 회원가입 기능의 요구사항 중 이미 가입된 ID로는 회원가입을 허용하지 않는다는 조건이 있었다.
이 요구사항을 검증하기 위해 나는 “회원가입이 중복될 때 Repository의 동작이 실제로 어떻게 되는가”에 초점을 맞추었다.

 

이때 중요한 것은 결과값이 아니라 행동(메서드 호출)이었다.
정상적인 회원가입이라면 userJpaRepository.save()가 한 번만 호출되어야 하고, 중복된 회원가입 시에는 save()가 다시 호출되지 않아야 한다. 이러한 호출 여부와 횟수를 검증하기 위해 Spy를 사용하였다.


Spy는 실제 객체를 프록시로 감싸, 기본 동작은 그대로 수행하면서도 “어떤 메서드가 몇 번 호출되었는가”를 추적할 수 있는 방식이다.

@MockitoSpyBean
private UserJpaRepository userJpaRepository;
    
@Test
    @DisplayName("이미 가입된 ID로 회원가입 시 실패한다.")
    void signUp_duplicate_id_fails() {
        //given
        UserRequestDto requestDto1 = new UserRequestDto(
                "sangdon",
                "dori@dori.com",
                "1998-02-21",
                "MALE"
        );
        User user1 = new UserDtoMapper().toEntity(requestDto1);

        UserRequestDto requestDto2 = new UserRequestDto(
                "sangdon",
                "karina@karina.com",
                "2000-02-21",
                "FEMALE"
        );
        User user2 = new UserDtoMapper().toEntity(requestDto2);

        //when
        userService.saveUser(user1);

        //then
        assertThrows(CoreException.class, () -> userService.saveUser(user2));
        verify(userJpaRepository, times(1)).save(user1);
        verify(userJpaRepository, never()).save(user2);
    }

 

 

 

이처럼 Spy를 사용하여 실제 비즈니스 흐름을 그대로 수행하면서도 필요한 메서드만 선택적으로 검증할 수 있었다.

 

이전까지는 크게 체감하지 못했지만, 한 번에 40개가 넘는 테스트 코드를 작성하며
정말 필요한 로직과 응답만 정확히 검증하는 것이 얼마나 중요한지 깨달았다.
불필요한 검증을 줄이고 핵심만 테스트하는 것이 곧 생산성과 직결된다는 사실을 몸으로 느꼈다.

 

 

반응형