유닛 테스트(unit test)는 컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차다.
즉, 모든 함수와 메소드에 대한 테스트 케이스(Test case)를 작성하는 절차를 말한다.
이를 통해서 언제라도 코드 변경으로 인해 문제가 발생할 경우, 단시간 내에 이를 파악하고 바로 잡을 수 있도록 해준다.
이상적으로, 각 테스트 케이스는 서로 분리되어야 한다. 이를위해 가짜 객체(Mock object)를 생성하는 것도 좋은 방법이다.
- 위키백과
배경
현재 내가 일하고 있는 조직에서는 테스트 코드 작성을 하지 않는다. 이로 인해 코드 수정이나 기능을 추가해야 할 때 수동으로 테스트를 진행해야 했다. 작은 변경사항에도 전체 기능을 다시 테스트해야 하는 상황이 자주 발생했고, 이는 상당히 소모적이라고 느껴졌다.
이런 경험을 통해 테스트 코드의 중요성을 더욱 실감하게 되었다. 테스트 코드가 있었다면 변경사항 검증이 더 효율적이고 신뢰할 수 있었을 거라고 생각한다. 이후 혼자 진행하는 프로젝트를 맡게 되었다. 테스트 코드를 작성하면서 개발을 하고싶었지만 일정에 맞춰 빠르게 개발을 해야 했기 때문에 테스트 코드를 작성하지 않고 개발을 진행했다. 이후 Mybatis에서 JPA로 마이그레이션하는 작업을 하면서 단위 테스트를 작성하기로 결정했다.
이번 프로젝트를 진행하면서 계층별 (Controller, Service, Repository) 단위 테스트 했던 내용을 정리하려고 한다.
본 글에서는 JUnit, MockMvc, Mockito를 사용한 Contorller 단위 테스트에 대해 다룬다.
잘못된 내용이 있거나 더 좋은 코드를 알고계신다면 마음껏 댓글에 남겨주세요.
개발 환경
- Java 8
- Spring Boot 2.x
- Gradle
- Spring Data JPA
- JSP
- JUnit, Mockito, MockMvc
- H2
- IntelliJ
구현
Gradle
dependencies {
...
// Testing
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-inline:5.2.0'
test {
useJUnitPlatform()
}
...
}
- org.springframework.boot:spring-boot-starter-test : Spring Boot 테스트를 위한 의존성. JUnit, AssertJ, Hamcrest, Mockito 등 테스트에 필요한 다양한 라이브러리가 포함되어 있다.
- org.mockito:mockito-inline:5.2.0 : final 클래스, static 메서드는 모킹할 수 있도록 해준다. static 메서드를 모킹할 필요가 없다면 의존할 필요 없다. 관련 내용은 별도 글을 작성했다. (링크)
- useJUnitPlatform() : JUnit5를 사용할 수 있도록 해준다. JUnit5는 자바를 위한 단위 테스트 프레임워크이다. 람다를 사용하기 때문에 Java8 이상부터 지원하고 있다. 테스트를 위한 편리한 기능들을 제공해준다.
Controller
단위 테스트를 진행할 Controller 클래스다. 예시를 위해 하나의 메서드만 남겨두었다.
getTaskManagerView() 메서드는 사용자가 관리자 또는 CEO라면 업무 관리 페이지 view를 담은 ModelAndView를 반환하고, 아니라면 예외를 발생시킨다.
@Slf4j
@RestController
@RequiredArgsConstructor
public class TaskManagerController {
private final TaskManagerService taskManagerService;
private final TotalService totalService;
/**
* 업무 관리 페이지로 이동합니다.
*/
@GetMapping("/am/tasks-manager")
public ModelAndView getTaskManagerView(HttpServletRequest request) {
ModelAndView mv = new ModelAndView();
Users user = SessionsUser.getSessionUser(request.getSession());
if (user.isCeo()) {
List<String> evaluationYearList = totalService.getEvaluationYearList();
String recentEvaluationYear = String.valueOf(evaluationYearList.get(0));
mv.addObject("yearList", evaluationYearList);
mv.addObject("selectedYear", recentEvaluationYear);
mv.addObject("userInfo", user);
mv.setViewName("/task/taskInfoList");
return mv;
} else {
throw new BusinessLogicException(ExceptionCode.ACCESS_DENIED);
}
}
}
@Getter
public enum ExceptionCode {
ACCESS_DENIED(401, "접근 권한이 없습니다.");
}
Test
아래는 ControllerTest 클래스이다. 부분별로 나누어서 확인해보자
@WebMvcTest(controllers = TaskManagerController.class,
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = WebMvcConfig.class))
class TaskManagerControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private TaskManagerService taskManagerService;
@MockBean
private TotalService totalService;
private MockedStatic<SessionsUser> sessionUser;
private final String BASE_URL = "/am/tasks";
@BeforeEach
void beforeEach() {
sessionUser = Mockito.mockStatic(SessionsUser.class);
}
@AfterEach
void afterEach() {
sessionUser.close();
}
@Test
@DisplayName("CEO는 업무 관리 페이지로 이동할 수 있다.")
void getTaskManagerViewSuccess() throws Exception {
// given
Users ceo = createDummyCeo();
sessionUser.when(() -> SessionsUser.getSessionUser(Mockito.any(MockHttpSession.class))).thenReturn(ceo);
List<String> evaluationYearList = Arrays.asList("2024", "2023", "2022");
given(totalService.getEvaluationYearList()).willReturn(evaluationYearList);
// when
mockMvc.perform(get("/am/tasks-manager")
.session(new MockHttpSession()))
.andExpect(status().isOk())
.andExpect(view().name("/task/taskInfoList"))
.andExpect(model().attribute("yearList", evaluationYearList))
.andExpect(model().attribute("selectedYear", evaluationYearList.get(0)))
.andExpect(model().attribute("userInfo", ceo))
.andDo(print());
}
}
@WebMvcTest(controllers = TaskManagerController.class,
excludeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes = WebMvcConfig.class))
class TaskManagerControllerTest {
...
}
- @WebMvcTest: 애플리케이션 전체가 아닌 Web 게층만 테스트할 때 사용한다. controllers 옵션을 사용해 Controller 클래스를 지정하여 해당 클래스만 인스턴스화하도록 할 수 있다. @SpringBootTest 애너테이션을 사용해도 똑같이 테스트는 가능하지만 아래와 같은 차이점이 있기 때문에 웹 계층 단위 테스트에서는 필요한 빈만 로드하는 @WebMvcTest를 사용하는 것이 바람직하다.
- @SpringBootTest: 전체 애플리케이션 컨텍스트를 인스턴스화하여 통합 테스트를 수행한다.
- @WebMvcTest: 웹 계층(컨트롤러)만 인스턴스화하여 단위 테스트를 수행한다.
- excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebMvcConfig.class): 프로젝트 내에서 인터셉터를 통해 특정 url 접근 시 로그인 인증을 하도록 설정했는데, 현재 테스트에서는 불필요한 단계이기 때문에 excludeFilters 옵션을 사용해 제외했다.
- @ComponentScan.Filter: 컴포넌트 스캔 과정에서 특정 클래스나 패키지를 포함하거나 제외할 대 사용한다.
- type = FilterType.ASSIGNABLE_TYPE: 필터 타입을 지정한다. ASSIGNABLE_TYPE은 특정 클래스나 인터페이스를 직접 지정할 때 사용한다.
- classes = WebMvcConfig.class: 제외할 클래스를 지정한다. 여기서는 WebMvcConfig 클래스를 제외했다.
@Autowired
private MockMvc mockMvc;
@MockBean
private TaskManagerService taskManagerService;
@MockBean
private TotalService totalService;
- MockMvc: Controller 테스트를 위한 모의 HTTP 요청을 생성한다.
- @MockBean: Spring 애플리케이션 컨텍스트에 Mock 객체를 생성한다. Mockito를 사용하여 Mock객체가 원하는 값을 반환하도록 설정할 수 있다. 헷갈릴만한 애너테이션으로 @Mock 애너테이션도 있는데 차이점은 다음과 같다.
- @MockBean: Spring 애플리케이션 컨텍스트를 로딩해야 사용할 수 있다. WebMvcTest는 Web Mvc 테스트에 필요한 제한된 애플리케이션 컨텍스트를 생성하기 때문에 @MockMvc를 사용하는 것이 적합하다.
- @Mock: Spring 애플리케이션 컨텍스트를 로딩하지 않기 때문에 단위 테스트에서 유용하게 사용될 수 있다. JUnit5에서 @ExtendWith(MockitoExtension.class) 애너테이션으로 사용할 수 있다.
@Test
@DisplayName("CEO는 업무 관리 페이지로 이동할 수 있다.")
void getTaskListSuccess() throws Exception {
// given
Users ceo = createDummyCeo();
List<String> evaluationYearList = Arrays.asList("2024", "2023", "2022");
given(SessionsUser.getSessionUser(Mockito.any(MockHttpSession.class))).willReturn(ceo);
given(totalService.getEvaluationYearList()).willReturn(evaluationYearList);
// when & then
mockMvc.perform(get("/am/tasks-manager")
.session(new MockHttpSession()))
.andExpect(status().isOk())
.andExpect(view().name("/task/taskInfoList"))
.andExpect(model().attribute("yearList", evaluationYearList))
.andExpect(model().attribute("selectedYear", evaluationYearList.get(0)))
.andExpect(model().attribute("userInfo", ceo))
.andDo(print());
}
- give-when-then: 테스트 케이스를 작성하는 패턴으로 BDD(Behavior-Driven Devlopment)에서 개발되었다. 해당 패턴을 통해 테스트의 가독성을 높이고 테스트 케이스의 구조를 일관되게 만들 수 있다.
- given: 테스트를 위한 초기 설정과 데이터 세팅
- when: 테스트하려는 실제 동작 수행
- then: 테스트 결과 검증
- given(totalService.getEvaluationYearList()).willReturn(evaluationYearList): Mockito에서 제공하는 기능으로 Mock 객체의 동작을 설정할 수 있다. 이 코드는 totalService.getEvaluationYearList() 메서드가 호출되면 evaluationyearList가 반환되도록 메서드의 동작을 설정하고 있다.
- given(): 메서드를 통해 Mock객체의 특정 메서드를 호출
- willReturn(): 메서드를 통해 메서드가 반환할 값을 지정한다.
- Mockito.any(): 메서드 호출 시 특정 타입의 파라미터가 들어가야하는지 알려준다. 예를 들어 Mockito.anyLong()이라면 Long 타입 파라미터가 들어가는 것을 말하고 Mockito.any(TaskEntity.class)라면 TaskEntity 타입의 어떤 객체라도 파라미터로 들어갈 수 있음을 의미한다.
- perform(get("/am/tasks-manager")): MockMvc를 사용해서 모의 HTTP 요청을 실행한다. 메서드 체이닝을 통해 요청 결과에 대한 검증을 할 수 있다. RequestBuilder객체를 파라미터로 받아 GET, POST, PUT, DELETE, PATCH 등 모든 HTTP 요청을 시뮬레이션 할 수 있다. 해당 코드에서는 "/am/tasks-manager" 경로에 GET 요청을 보내는 것처럼 시뮬레이션 한다. (참고) HTTP 요청 설정을 조금 더 상세히 하고싶다면 빌더 패턴을 사용할 수 있다.
perform(get("/am/tasks-manager")
// Request Body에 데이터를 추가
.content("requestBody")
// header content type 설정
.contentType("text/plain")
// response datatype 설정
.accept("application/json")
// 커스텀 header 추가
.header("headerName","headerValue")
// HTTP 또는 HTTPS 사용 여부 설정
.secure(true))
- andExpect(): HTTP 요청에 대한 결과가 기대값과 일치하는지 검증한다. MockMvcResultMatchers 객체의 메서드를 파라미터로 받아서 응답에 대한 다양한 검증을 할 수 있다.
mockMvc
.perform(get("/am/tasks-manager"))
// HTTP 상태 코드 검증
.andExpect(status().isOk())
// Response content type 검증
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
// Response content 내용 검증
.andExpect(content().string("expectedContent"))
// view 이름 검증
.andExpect(view().name("expectedViewName"))
// model 속성 검증
.andExpect(model().attribute("username", "expectedUsername"))
// JSON 응답 검증
.andExpect(jsonPath("$.property").value("expectedValue"))
// 리다이렉트 URL 검증
.andExpect(redirectedUrl("expectedUrl"));
// 직접 검증. 아래 코드에서는 예외 메시지가 일치하는지 검증하고 있다.
.andExpect(result -> assertEquals(ExceptionCode.ACCESS_DENIED.getMessage(), Objects.requireNonNull(result.getResolvedException()).getMessage())
- andDo(print()): 요청과 응답의 세부 정보를 콘솔에 출력한다.
Test 결과
테스트가 잘 통과된 것을 확인할 수 있다. 아래처럼 콘솔창에 응답에 대한 정보가 나오는 건 andDo(print()) 설정이 되어있기 때문이다.
콘솔 내용과 예상한 결과값이 모두 일치하는 것을 확인할 수 있다.
추가
excludeFilter를 사용해서 WebMvcConfig 클래스를 제외한 이유
WebMvcConfig는 WebMvcConfigurer 인터페이스를 구현하여 인터셉터를 생성하고 있다.
여기서 구현된 인터셉터는 특정 url에 접근 시 로그인 여부 또는 권한이 있는지 체크하는 역할을 하고 있다.
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final LoginCheckInterceptor loginCheckInterceptor;
private final AccessInterceptor accessInterceptor;
/**
* 로그인 인증 Interceptor 설정
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginCheckInterceptor)
.order(0)
.addPathPatterns("/am/tasks/**", "/am/tasks-manager/**", "/am/tasks-evaluation/**", "/am/totals/**", "/am/admin/**", "/am/member/**")
.excludePathPatterns("/am/manager/login", "/am/manager/loginProc");
registry.addInterceptor(accessInterceptor)
.order(1)
.addPathPatterns("/am/tasks/**", "/am/tasks-manager/**", "/am/totals/**", "/am/admin/**");
}
}
@WebMvcTest 애너테이션은 @TypeExcludeFilters(WebMvcTypeExcludeFilte.class)를 포함하고 있다. WebMvcTypeExcludeFilte 클래스는 Web Mvc 테스트에 관련된 빈들만 스캔하도록 스캔 대상을 지정한다.
해당 클래스를 살펴보면 WebMvcConfigurer를 스캔 대상으로 지정한 것을 확인할 수 있다.
public final class WebMvcTypeExcludeFilter extends StandardAnnotationCustomizableTypeExcludeFilter<WebMvcTest> {
...
static {
Set<Class<?>> includes = new LinkedHashSet();
includes.add(ControllerAdvice.class);
includes.add(JsonComponent.class);
includes.add(WebMvcConfigurer.class); // 여기
includes.add(WebMvcRegistrations.class);
includes.add(Filter.class);
includes.add(FilterRegistrationBean.class);
includes.add(DelegatingFilterProxyRegistrationBean.class);
includes.add(HandlerMethodArgumentResolver.class);
includes.add(HttpMessageConverter.class);
includes.add(ErrorAttributes.class);
includes.add(Converter.class);
includes.add(GenericConverter.class);
includes.add(HandlerInterceptor.class);
String[] var1 = OPTIONAL_INCLUDES;
int var2 = var1.length;
for(int var3 = 0; var3 < var2; ++var3) {
String optionalInclude = var1[var3];
try {
includes.add(ClassUtils.forName(optionalInclude, (ClassLoader)null));
} catch (Exception var6) {
}
}
DEFAULT_INCLUDES = Collections.unmodifiableSet(includes);
includes = new LinkedHashSet(DEFAULT_INCLUDES);
includes.add(Controller.class);
DEFAULT_INCLUDES_AND_CONTROLLER = Collections.unmodifiableSet(includes);
}
}
그래서 내가 작성한 인터셉터도 컨트롤러 단위 테스트에 포함되어 동작하게 된다.
인터셉터가 컨트롤러 단위 테스트에 포함되면 내가 의도하지 않은 테스트 결과가 나올 수 있다.
실제로 exlcudeFilter 옵션을 제외해서 인터셉터를 포함한 테스트를 돌려보자
@WebMvcTest(controllers = TaskManagerController.class)
class TaskManagerControllerTest {
...
}
테스트에 실패한 것을 확인할 수 있다. 예상한 status와 실제 결과 status가 다르다.
그리고 Response를 살펴보면 Redirect URL에 로그인 url(/am/manager/login)로 리다이렉트 시키려는 것을 알 수 있다.
이런 결과가 나온 이유는 컨트롤러에 요청이 전달되기 전에 인터셉터(LoginCheckInterceptor)에서 로그인 여부를 체크하고 로그인 사용자가 아니면 로그인 url로 리다이렉트하도록 했기 때문이다.
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession();
String requestURI = request.getRequestURI();
String loginURI = "/am/manager/login";
String loginProcURI = "/am/manager/loginProc";
if (requestURI.equals(loginURI) || requestURI.equals(loginProcURI)) {
return true;
}
if (!SessionsUser.isLoginUser(session)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.sendRedirect(loginURI);
return false;
}
return true;
}
}
이렇듯 컨트롤러만을 단위 테스트하려는 우리의 의도와 달리, 인터셉터가 테스트에 포함되어 예상치 못한 결과를 초래할 수 있다. 이를 방지하기 위해 WebMvcConfig를 스캔 대상에서 제외함으로써, 컨트롤러의 동작만을 독립적으로 테스트할 수 있도록 설정했다. 이렇게 함으로써 컨트롤러의 로직에 집중하여 정확하고 신뢰할 수 있는 단위 테스트를 수행할 수 있게 되었다.
Service, Repository Layer 단위 테스트가 궁금하다면 아래 링크를 통해 확인 할 수 있다.
이제 직접 테스트 코드를 작성해보자!
참조
https://www.arhohuttunen.com/spring-boot-webmvctest/
https://aws.amazon.com/what-is/unit-testing/
https://spring.io/guides/gs/testing-web
https://codingnomads.com/java-spring-mockmvc
https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/authentication.html
'Spring' 카테고리의 다른 글
Spring Boot - Repository 단위 테스트하기(JPA, Querydsl, Mybatis) (0) | 2024.12.31 |
---|---|
Spring Boot - Service 단위 테스트하기(JUnit5, Mockito, AssertJ) (1) | 2024.12.16 |
Spring Boot - JPA 스키마, 데이터 초기화하기 (SQL script -> ddl-auto) (1) | 2024.11.13 |
Spring Boot Test - Mockito로 Static Method Mock 만드는 방법 (2) | 2024.11.01 |
Spring Interceptor - 인터셉터로 로그인 체크하기 (0) | 2023.09.12 |