본문 바로가기

TIL

유닛테스트와 통합테스트

Visual Summary - 학습 이미지

테스트 피라미드

테스트 커버리지 100프로 달성 목표를 위한 테스트 코드 작성

어제 내가 공부하고 작성한 부분은 유닛단위 테스트를 작성 하는 방법이었다.

그렇게 도메인에있는 서비스를 하나하나 유닛 테스트를 모두 작성을 완료 하였다.

@ExtendWith(MockitoExtension.class)
public class TodoServiceTest {

    @Mock
    private TodoRepository todoRepository;
    @Mock
    private WeatherClient weatherClient;
    @InjectMocks
    private TodoService todoService;

    @Test
    public void Todo를_성공적으로_생성한다(){
        //given
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
        TodoSaveRequest todoSaveRequest = new TodoSaveRequest("할일 제목", "어쩌구 저쩌구");

        User user = User.fromAuthUser(authUser);
        String weather = "SUNNY";
        Todo todo = new Todo("할일 제목", "어쩌구 저쩌구", weather, user);

        // -> todoRepository.save(any()) -> willReturn to-do
        given(todoRepository.save(any())).willReturn(todo);
        given(weatherClient.getTodayWeather()).willReturn(weather);

        //when
        TodoSaveResponse result = todoService.saveTodo(authUser , todoSaveRequest);

        //then
        assertNotNull(result);
        verify(todoRepository).save(any());
    }
}

완성한 todoServiceTest

 

기본적으로 강의에 나왔던 대로 given / when / then 패턴으로 작성하였고, 각각의 어노테이션은 아래와 같은 의미를 담고있다는 것을 학습했다.

@ExtendWith(MockitoExtension.class) 이 테스트는 유닛단위 테스트를 할거라 Mockito을 사용할거다 선언
@Mock 실제 객체 대신 사용하는 가짜 객체
@IngectMocks mock으로 생성된 데이터를 주입해서 테스트를 할 객체

 

그러나 커버리지에 controller는 포함이 안되더군요..

그래서 컨트롤러도 서비스와 똑같이 유닛테스트를 해보았습니다!

@ExtendWith(MockitoExtension.class)
class TodoControllerTest {

    @Mock
    private TodoService todoService;

    @InjectMocks
    private TodoController todoController;

    @Test
    void saveTodo_성공() {
        // given
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
        TodoSaveRequest request = new TodoSaveRequest("title", "description");
        UserResponse userResponse = new UserResponse(authUser.getId(), authUser.getEmail());
        TodoSaveResponse todoSaveResponse = new TodoSaveResponse(1L, "title", "description", "SUNNY", userResponse);

        given(todoService.saveTodo(authUser, request)).willReturn(todoSaveResponse);

        // when
        ResponseEntity<TodoSaveResponse> response = todoController.saveTodo(authUser, request);

        // then
        assertNotNull(response);
        assertEquals(HttpStatus.OK, response.getStatusCode());
        assertEquals(todoSaveResponse, response.getBody());
    }
}

정상적으로 동작은하고 커버리지도 포함이 되었는데 controller에서 이런 방식으로 테스트 진행 시 HTTP 레이어 검증이 안된다는 점이있습니다. 그렇기 때문에 여기선 통합 테스트를 진행 해 주었습니다. 

그럼 통합 테스트와 유닛 테스트의 차이는 무엇이냐?

  통합 테스트 유닛 테스트
테스트속도 느림 빠름
HTTP 레이어 검증 O X
실행 복잡도 높음  낮음
DB 사용 O X
Spring Context O X
의존성 실제 객체 사용 Mock 사용

그렇기때문에 컨트롤러/config 등은 통합테스트로 진행 하였습니다.

 

자, 이제부터 통합테스트 코드를 한번 보겠습니다.

이제 한바탕 코딩 해보죠, 내릴사람은 지금 내리시죠ㅎㅅㅎ

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class TodoControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private TodoRepository todoRepository;

    @Autowired
    private JwtUtil jwtUtil;

    private String token;

    @BeforeEach
    void setUp() {
        // todo setup
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
        User user = User.fromAuthUser(authUser);
        Todo todo = new Todo("Title","contents","SUNNY",user);
        todoRepository.save(todo);

        token = jwtUtil.createToken(authUser.getId(),authUser.getEmail(),authUser.getUserRole());
    }
    
    @Test
    void saveTodo_성공() throws Exception {
        String requestBody = """
                {
                "title": "Title",
                "contents": "contents"
                }
                """;

        mockMvc.perform(post("/todos")
                        .header("Authorization", token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(requestBody))
                .andExpect(status().isOk());
    }
}

간단하게 todoSave 만 가져와봤습니다.

 

통합 테스트를 하기위해선 @SpringBootTest @AutoConfigureMockMvc을 선언 해주어야합니다.

@BeforeEach 어노테이션을 활용하여 각 테스트 메서드가 실행 되기 전에 필요한 데이터를 미리 세팅 해줍니다.

@BeforeEach
    void setUp() {
        // todo setup
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);
        User user = User.fromAuthUser(authUser);
        Todo todo = new Todo("Title","contents","SUNNY",user);
        todoRepository.save(todo);

        token = jwtUtil.createToken(authUser.getId(),authUser.getEmail(),authUser.getUserRole());
    }

그리고 통합 테스트에선 SpringBoot를 실제로 실행 시키기 때문에 @Autowired 를 통해 DI를 해줍니다. 

@Autowired
private MockMvc mockMvc;

@Autowired
private TodoRepository todoRepository;

@Autowired
private JwtUtil jwtUtil;

 

테스트 코드 내부에서 mockMvc를 호출하여 post테스트를 진행 해주었습니다

MockMvc는 실제 서버를 실행하지 않고도 HTTP 요청/응답을 시뮬레이션할 수 있어
Controller → Service → Repository 흐름을 테스트할 수 있습니다.

@Test
    void saveTodo_성공() throws Exception {
        String requestBody = """
                {
                "title": "Title",
                "contents": "contents"
                }
                """;

        mockMvc.perform(post("/todos")
                        .header("Authorization", token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(requestBody))
                .andExpect(status().isOk());
    }

 

트러블슈팅

jwtFilter에서 아무리 테스트를 진행해도 해당 코드가 커버리지에 포함되지 않는 이슈가 있었습니다..

Claims claims = jwtUtil.extractClaims(jwt);
            if (claims == null) {
                log.warn("Claims 추출 실패: URI={}", url);
                sendErrorResponse(httpResponse, HttpStatus.UNAUTHORIZED, "인증이 필요합니다.");
                return;
            }

지금 생각해보면 당연하게 테스트 코드 작성 시 모든 토큰은 jwt Util에있는 createToken으로 만들어 주었는데.

여기는 email이나 userRole을 null로 입력해도, 어차피 claim에 null 값이 들어가서, 해당 분기에 걸리질 않았습니다.

즉,claims는 존재하는데, 그 안의 값이 null(또는 아예 키가 없음)일 뿐이라서 안 걸렸습니다.

그래서 claims가 아예없는 토큰을 만들고 테스트를 진행하였습니다.

    @Mock
    private JwtUtil jwtUtil;

    @Test
    void changeUserRole_정보없는_토큰() throws Exception {
        given(jwtUtil.substringToken(any())).willReturn("sometoken");
        given(jwtUtil.extractClaims("sometoken")).willReturn(null);

        mockMvc.perform(patch("/admin/users/{userId}", 1)
                        .header("Authorization", "Bearer fakefakefakefakefakefakefakefakefakefake")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{}"))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.message").value("인증이 필요합니다."));
    }

그리고 테스트를 돌려보는데 이상하게 jwt토큰 형식이 올바르지 않다는 에러가 나옵니다.

 

이유는 @Mock private JwtUtil jwtUtil; 을 사용했기 때문입니다.

@Mock으로 만든 객체는 테스트 클래스 내부에서만 존재하는 Mockito mock이라, MockMvc 요청을 처리할 사용하는 스프링 컨테이너의 실제 Bean(JwtUtil) 대체하지 못합니다.

즉, JwtUtil 를 실제로 대체한게 아니라 실제 빈을 가져와 쓰고있었던 것 입니다.

따라서 MockMvc 기반(/통합) 테스트에서는 스프링 빈을 mock으로 교체하는 @MockBean 을 사용해야 한다는 것을 알게되었습니다.

@Mock
private JwtUtil jwtUtil;

@MockBean 으로 변경 후 원하는 에러가 정상적으로 발생하는 것을 확인.

테스트 결과

커버리지를 100%프로 달성해버린 나...

 

총 70개의 테스트 코드를 작성하였고, 라인 커버리지와 브랜치 커버리지 100프로를 달성하였습니다.

 

회고 및 확장

사실 커버리지 100프로를 달성하면 100프로 신뢰할 수 있는 코드라는 것은 아니다. 오히려 테스트가 필요없는 (spring에서 기본적으로 만들어주는) 코드나 안티패턴이 나올 수 있는 부분이 있을 수 있기에 오히려 신뢰성이 떨어진다고 생각이 들긴 하였다. 

이번 테스트를 작성하면서 특히 느낀 점은 테스트의 목적은 커버리지를 채우는 것이 아니라 코드의 동작을 검증하는 것이라는 점이었다. 따라서 앞으로는 단순히 커버리지를 높이는 것보다 다음과 같은 방향으로 테스트를 작성하는 것이 더 중요하다고 생각한다.

'TIL' 카테고리의 다른 글

네트워크란 무엇인가 ( 네트워크는 마법이 아니다. )  (0) 2026.03.09
ArgumentResolver  (0) 2026.03.06
테스트코드  (0) 2026.03.04
AOP  (0) 2026.03.03
github - 협업준비하기.  (0) 2026.02.15