관리 메뉴

백엔드 엔지니어 이재혁

[JUnit] Controller 단위 테스트 작성하기, 그리고 회고 본문

Java

[JUnit] Controller 단위 테스트 작성하기, 그리고 회고

alex00728 2025. 6. 29. 00:46

Controller 테스트 코드는 HTTP 요청을 받는 경우들을 확인하면 된다.

Controller에서 응답이 잘 되는지 혹은 잘못된 요청에 대한 차단이 되는지 확인하자.

 

말이 어렵다면, 예시를 한 번 보자.

 

1. 인증/권한 검증

@Test
@DisplayName("일반 회원은 자격증 리스트를 볼 수 없다")
void getCertList_NonAdmin() throws Exception {
    // Given
    Role nonAdminRole = new Role();
    nonAdminRole.setRname("ROLE_USER");
    User nonAdminUser = new User();
    nonAdminUser.setRole(nonAdminRole);
    when(userService.getUserInfo("user")).thenReturn(nonAdminUser);

    // When
    Exception exception = null;
    try {
        mockMvc.perform(post("/api/user/admin/cert"));
    } catch (ServletException e) {
        exception = e;
    }

    // Then
    assertInstanceOf(AdminAuthException.class, exception.getCause());
}

 

2. 파일 업로드 제한 검증

@Test
@DisplayName("프로필 이미지 5MB 이상 사용 금지")
void updateProfileImage_Failure_jpg5MB() throws Exception {
    // Given
    User user = new User();
    user.setProfileImage("http://s3.aws.amazon.com/abc.png");
    when(tokenAuth.extractUsernameFromToken(any())).thenReturn("user");
    when(userService.getUserInfo("user")).thenReturn(user);
    when(s3UploadService.saveProfileImageFile(any())).thenReturn("http://s3.aws.amazon.com/abc.jpg");
    MockMultipartFile jpg = new MockMultipartFile(
        "imageFile", "test.jpg", "image/jpeg", new byte[5 * 1024 * 1024]);

    // When & Then
    mockMvc.perform(multipart("/api/user/update/profile-image")
            .file(jpg))
        .andExpect(status().isBadRequest());
}

 

3. DTO 정상 응답 검증

@Test
@DisplayName("사용자 정보 조회")
void getUser() throws Exception {
    // Given
    UserInfoDTO expectedResponse = new UserInfoDTO();
    expectedResponse.setNickname("testUser");
    expectedResponse.setPhoneNumber("01011112233");
    expectedResponse.setCertno("11223344");
    expectedResponse.setCreatedAt(LocalDateTime.now().toString());

    when(tokenAuth.extractUsernameFromToken(any())).thenReturn("user");
    when(userService.getUser("user")).thenReturn(expectedResponse);

    // When
    ResultActions performed = mockMvc.perform(get("/api/user/info"));

    // Then
    performed.andExpect(status().isOk())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andDo(print())
        .andExpect(jsonPath("$.nickname").value(expectedResponse.getNickname()))
        .andExpect(jsonPath("$.phoneNumber").value(expectedResponse.getPhoneNumber()))
        .andExpect(jsonPath("$.certno").value(expectedResponse.getCertno()))
        .andExpect(jsonPath("$.createdAt").value(expectedResponse.getCreatedAt()));
}

 

4. 잘못된 URL에 요청을 한 경우, 404 Not Found를 반환하는지 검증

@Test
@DisplayName("잘못된 경로 요청은 404 에러를 반환해야 한다")
void requestInvalidURL() throws Exception {
    mockMvc.perform(get("/api/user/info/invalid-path"))
        .andExpect(status().isNotFound());
}

 

5. 잘못된 path variable을 전달한 경우, 400 Bad Request를 반환하는지 검증

@Test
@DisplayName("Product ID는 문자열이 아닌 숫자를 입력 받아야 한다")
void getResourceWithInvalidPathVariable() throws Exception {
    mockMvc.perform(get("/api/product/abc"))
        .andExpect(status().isBadRequest());
}

 

위 예시 중에서 특히 1번과 2번, 권한 검증과 파일 검증이 정말 중요한 부분이라고 생각한다. 보안사고와 직결되는 부분이다.

 

 

회고?

단위 테스트를 작성하다보니 원본 코드가 다시 보인다.

현재 대부분의 Controller가 `return ResponseEntity.ok().build();` 같이 HTTP 응답을 구체적으로 설정해주고 있다. Controller의 역할은 이게 맞다고 생각한다.

 

그런데, 관리자 권한을 확인하는 단계에서는 HTTP 응답을 설정하지 않고 예외를 던지도록 만들어뒀다.

private void checkAdminPrivileges(HttpServletRequest request) {
    User requestedUser = userService.getUserInfo(tokenAuth.extractUsernameFromToken(request));
    if (requestedUser == null || !requestedUser.getRole().getRname().equals("ROLE_ADMIN")) {
        throw new AdminAuthException("관리자 전용 기능입니다.");
    }
}

 

그리고 `GlobalExeptionHandler`에서 `AdminAuthException`을 어떻게 핸들링할지 정의해놨다.

@ExceptionHandler(AdminAuthException.class)
public ResponseEntity<Map<String, String>> handleAdminAuthException(AdminAuthException ex) {
    Map<String, String> errorResponse = new HashMap<>();
    errorResponse.put("status", "error");
    errorResponse.put("message", ex.getMessage());
    return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
}

 

Admin 권한이 필요한 요청인데, Admin 권한이 없는 사용자가 요청한다면 이건 명확히 문제가 있는 상황이라고 생각한다.

이렇게 명백한 문제가 있을 때는 어플리케이션에서 Exception을 발생시키는 것이 코드를 보는 입장에서 "이 상황은 문제가 있는 상황이구나!"를 직관적으로 알 수 있다고 생각한다.

 

새로운 발견

`UserController`의 단위 테스트를 작성하다가 Admin 권한이 없는 경우에 403 에러가 반환되는지 확인하는 테스트를 작성해보려고 했더니, 컨트롤러는 `AdminAuthException`을 던지는 책임만 있고, 403 에러 HTTP 응답을 하는 책임은 `GlobalExceptionHandler`에 있다. 이 경우 단위 테스트를 작성한다면, HTTP 응답을 검증하는 것이 아니라 `AdminAuthException`이 터지는지를 검사해야 한다.

 

과연 HTTP 응답 책임을 `GlobalExceptionHandler`로 넘기는 것이 맞을까?

 

단위 테스트 명확성

단위 테스트에서 일관되고 명확한 코드를 유지할 수 있는지 고민해보자.

1. `UserController`에서 `AdminAuthException` 예외를 던지는 경우, 이 예외는 관리자 권한이 없는 경우를 명확하게 정의하고 있다.

2. `UserController`에서 403 에러를 반환하는 경우, 정확히 어떤 권한이 없는 것인지 구분할 수 없다. 꼭 Admin 말고도 다른 권한에 대한 검증이 필요한 경우도 있다. 그런 모든 권한 문제에 대해서 403 Forbidden이 적절한 에러다. 정확히 Admin 권한이 없는지 확인하는게 아니라 광범위한 접근 권한 에러이기 때문에 테스트 케이스를 명확하게 검증하지 못한다고 생각한다.

 

단위 테스트 입장에서는 명확한 테스트 케이스를 구성할 수 있는 방식, `AdminAuthException`을 던지는 방식이 좋다고 생각한다.

 

더불어, 단위 테스트는 술술 읽히는 것이 중요하다고 생각하는데, 가독성 측면에서도 구체적이고 명확한 것이 좋다.

 

원본 코드 유지보수성

`AdminAuthException`은 꼭 `UserController` 뿐 아니라, 다른 Controller에서도 사용될 수 있다. 여러 Controller에서 동일한 문제 상황이 발생할 수 있다면, 그 상황들을 하나로 모아서 관리해주는 것이 코드의 일관성과 중복 코드 방지에 좋은 방향이라고 생각한다.

 

결론

Controller에서 HTTP 응답을 전달하는 것보다 예외를 던지는 방향이 더 좋아보인다.

 

Controller가 HTTP 요청에 대한 응답 책임을 완전히 가져가지 않는 것이 Controller의 근본 존재 이유를 해치는 것은 아닐까라는 고민을 했었지만 

  1. 단위 테스트 입장에서도
  2. 원본 코드 유지보수성에서도

명확성과 일관성을 확보하기 좋은 방식이 HTTP 응답을 구체적으로 정의하지 않고 예외만 던지는 것이 좋은 방식이라고 생각한다.

'Java' 카테고리의 다른 글

[JPA] N+1  (0) 2025.09.15
[Java] Executor 프레임워크 심화  (0) 2025.06.30
[Java] Executor 프레임워크 기본  (0) 2025.06.25
[Java] 컬렉션 프레임워크와 동시성  (0) 2025.06.13
[JAVA] CAS 락 구현  (0) 2025.06.10