Search
🧪

단위테스트 작성하기 with @WebMvcTest

태그
spring boot 3
Test
회원가입
로그인
분류
Spring Boot
목차

 테스트 코드 완성본을 보고싶다면?

7
issues

단위 테스트 와 통합 테스트

화이트 박스와 블랙 박스 검사

화이트 박스 검사는 내부 소스 코드를 테스트하는 기법입니다. 반면 블랙 박스 검사의 경우 내부를 보지 않고 입출력을 확인하여 기능이 잘 돌아가는지 확인합니다. 때문에 화이트 박스 테스는 주로 개발자가 합니다. 코드를 기반으로 테스트하기 때문에 동작을 추적할 수 있고, 실행 과정을 살펴보며 불필요한 코드가 있는지 확인할 수 있기 때문이죠. 반면 블랙박스 검사는 내부 구조나 작동 원리를 모르는 상태에서 동작을 검사합니다. 때문에 대외적으로 공개된 사항들을 기반으로 테스트를 하고, 요구 사항 등에 초점을 맞춰 검사가 이뤄집니다. 때문에 테스터들이 주로 해당 기법을 사용합니다.

단위 테스트

제가 진행하고 있는 프로젝트에는 많은 기능이 있습니다. 회원가입, 로그인, 유저 정보 변경 등… 다양한 기능이 있는데, 이 중 회원가입과 같은 특정 기능만 테스트 해보는것이 단위 테스트 입니다.
테스트 코드를 작성하여 동작 해보고 이상 유무를 판별합니다. 모듈 한개를 테스트 하기 때문에 모듈 테스트라고 하며, 화이트 박스 테스트 기법을 주로 사용 합니다. 외부 서비스(DB) 와 연동이 되면 안되기 때문에 가짜 객체를 만들어 테스트를 수행합니다.

통합 테스트

통합 테스트의 경우 단위 테스트를 마친 모듈을 합치는 과정에서 발생할 수 있는 오류를 찾는 테스트 입니다. 규모가 커질수록 여러 모듈이 유기적인 관계를 맺고 있기 때문에 이러한 모듈을 결합한 형태로 테스트를 수행해야 합니다. 이때 모듈 간의 상호작용이 정상적으로 수행 되는가? 를 위주로 봅니다. 즉, 모듈이 올바르게 맞물려 동작하고 있는지, 외부 서비스와의 연동에서 오류가 나타나지 않는지 등을 확인 합니다.
통합 테스트의 경우 모듈 통합을 한꺼번에 하는 빅뱅 테스트와 점진적으로 모듈 통합을 하는 기법 두가지가 존재합니다. 전자의 경우는 소규모 프로그램이나 프로그램의 일부를 대상으로 합니다. 규모가 커지면 원인을 찾기 힘들다는 단점이 존재하죠. 하지만 절차가 간단하고 쉽다는 장점이 있습니다.
후자의 경우는 점진적으로 모듈 통합을 하며 테스트를 수행하는 경우 오류가 발생했을 때 원인을 찾기 쉽습니다. 종류로는 하향식 기법과 상향식 기법이 있습니다. 하향식 기법의 경우 최상위 모듈 부터 하위 모듈(stub) 을 통합하는 방식입니다. 일반적으로 상위 모듈은 시스템 전체의 흐름을 관장하고, 하위 모듈은 각 기능을 구현합니다. 때문에 하향식 기법을 사용하면 오류를 일찍 발견하기 쉽습니다. 반면 상향식 기법의 경우 최하위 모듈 부터 테스트를 시작합니다. 최하위 모듈을 충분히 테스트 할 수 있다는 것이 장점이지만 상위 모듈(test driver) 에 문제가 생기면 하위 모듈을 다시 테스트 해야 합니다.

@WebMvcTest 와 @SpringBootTest

🫘
@WebMvcTest 와 @SpringBootTest 는 빈의 등록 범위와 MockMvc 를 주입 받는 것에 차이가 존재합니다.

Mock

가짜 객체를 만들어서 테스트하는 기술입니다. 이 과정에서 만들어진 가짜 객체를 MockMvc 라고 합니다. 이는 @Autowired 로 주입받아서 사용할 수 있습니다.

@SpringBootTest

@SpringBootTest 의 경우 프로젝트 내부에 있는 스프링 빈을 모두 등록합니다. 때문에 테스트가 느리지만 실제 환경과 가장 유사하게 테스트가 가능합니다.

@SpringBootTest 에서 MockMvc 주입하기

@SpringBootTest 는 MockMvc 를 빈으로 등록시키지 않습니다. 따라서 @AutoConfigureMockMvc 를 사용합니다.
@SpringBootTess class SpringBootTest { @Autowired MockMvc mockMvc; }
Java
복사

@WebMvcTest

컨트롤러의 역할만을 테스트 합니다.(슬라이스 테스트) 때문에 Web Layer 에 해당하는 빈만 빠르게 생성합니다. 필요한 경우 직접 빈을 추가 할 수 있으며, 테스트 속도가 빠르다는 장점이 있습니다. 하지만 Mock 객체를 사용하기 때문에 실제 동작과 차이가 존재하며, Mocking 메서드의 변경이 일어나면 수정이 필요합니다.

@WebMvcTest 에서 MockMvc 주입하기

@WebMvcTest 는 MockMvc 를 빈으로 등록 합니다. 따라서 아래와 같이 선언만 해주면 됩니다.
@WebMvcTest class WebMvcTest { @Autowired MockMvc mockMvc; }
Java
복사

@WebMvcTest 가 등록하는 빈의 종류

@Controller, @RestController
@ControllerAdvice, @RestControllerAdvice
@JsonComponent
Filter
WebMvcConfigurer
HandlerMethodArgumentResolver

Repository 와 Service 는요?

@Autowired 가 아닌 @MockBean 을 사용하여 Mock 객체에 빈으로 등록해야 합니다.

무엇 을 이용하여 어떤 테스트를 할까?

테스트의 목적

회원가입, 로그인이 제대로 수행 되는지 확인하고 테스트 결과를 사용하여 Spring Rest Docs 를 만들기 위해 테스트를 수행하려 합니다.

@SpringBootTest 와 @WebMvcTest

회원 가입과 로그인의 Controller 만 테스트를 수행해도 충분하기 때문에 @WebMvcTest 를 사용하기로 했습니다. 회원 가입에서 주로 확인할 것은 제약사항으로 걸어두었던 패턴 위반시에 알맞은 오류를 반환 하는지의 여부 입니다. 이는 외부 서비스와의 연동이 필요 없고 가짜 객체를 만들어 수행하는 것만으로도 충분하다는 판단을 했습니다.
로그인의 경우 알맞은 값을 넣을 때만 생각했을때는 @WebMvcTest 만으로도 충분하다 생각했습니다. 하지만 존재하지 않는 이메일로 로그인을 하거나 이메일은 맞지만 비밀번호를 잘못 입력 하였을 때가 문제였습니다. 이를 테스트 하기 위해서는 외부 서비스 즉, 데이터 베이스와 연결하여 해당 데이터가 있는지 존재 유무를 판단해야 합니다. 따라서 @SpringBootTest 를 사용하기로 하였습니다. 데이터 베이스는 간단하게 H2 를 사용하기로 하였습니다.

결론

결론적으로 아래와 같이 테스트를 진행하기로 하였습니다.

회원가입

알맞은 값을 이용한 회원가입
이메일 양식이 맞지 않는 경우 알맞은 예외를 반환하는가?
이미 존재하는 이메일 입력시 알맞은 예외를 반환하는가?
조건에 맞지 않는 닉네임 입력시 알맞은 예외를 반환하는가?
조건에 맞지 않는 비밀번호 입력 시 알맞은 예외를 반환하는가?

로그인

알맞은 값을 이용한 로그인
존재하지 않는 이메일을 입력할 경우 알맞은 예외를 반환하는가?
이메일은 맞지만 비밀번호를 잘못 입력한 경우 알맞은 예외를 반환하는가?

Spring Security 로 인한 401 / 403 상태코드 해결하기

Spring security 를 사용하는 application 의 테스트로 @WebMvcTest 사용시 401, 403 상태코드가 반환됩니다. 정상적으로 테스트코드를 다 작성한것 같은데도 불구하고 말이죠. 왜 그런 걸까요?
이전에 @WebMvcTest 는 컨트롤러 테스트를 위해 필요한 빈들만 등록합니다. 때문에 직접 custom 한 시큐리티 configuration 이 등록되지 않습니다. 따라서 custom 한 AuthenticationEntryPoint 도 가져올 수 없어요! 대신 spring security 가 자동으로 구성하는 configuration 을 가져옵니다.
따라서 인증된 객체를 넘겨주지 않는 경우 401 이 반환 되며, 인증된 유저지만 권한이 맞지 않아 인가과정을 거칠 수 없는 경우 403 을 반환 합니다.

401 상태코드 해결하기

@WebMvcTest(UserController.class) @WithMockUser // withMockUser 사용하여 인증된 사용자를 넣어준다.
Java
복사

403 상태코드 해결하기

위와 같이 인증된 사용자를 넣어도 csrf 문제를 해결해주지 않으면 계속 403 오류가 발생합니다. 세션/쿠키를 사용하여 상태 유지를 하는 경우 csrf 를 사용하는 것이 일반적이지만 rest api 의 경우 요청이 세션에 의존하지 않기 때문에 보통 csrf 기능을 disable 처리 합니다. 하지만, 기본으로 제공되는 설정에서는 아니므로 아래와 같이 csrf 기능을 사용해야 합니다.
@DisplayName("회원 가입") @Test void signup() throws Exception { SignupRequest request = new SignupRequest( "wldsmtldsm65@gmail.com", "123qwe!@#QWE", "시니", LocalDate.parse("1996-11-20") ); doNothing() .when(signupService) .signup(any(SignupRequest.class)); mockMvc .perform(post("/api/v1/auth/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) ...
Java
복사

알맞은 요청으로 인한 상태코드 200

회원가입

@DisplayName("회원 가입") @Test void signup() throws Exception { SignupRequest request = new SignupRequest( "wldsmtldsm65@gmail.com", "123qwe!@#QWE", "시니", LocalDate.parse("1996-11-20") ); doNothing() .when(signupService) .signup(any(SignupRequest.class)); mockMvc .perform(post("/api/v1/auth/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andDo(MockMvcRestDocumentation.document("signup", ApiDocumentUtil.getDocumentRequest(), ApiDocumentUtil.getDocumentResponse(), PayloadDocumentation.requestFields( PayloadDocumentation.fieldWithPath("email").description("이메일"), PayloadDocumentation.fieldWithPath("password").description("영어, 숫자, 특수 문자 포함 8 - 20 자리 비밀번호"), PayloadDocumentation.fieldWithPath("nickname").description("특수 문자를 포함 하지 않는 2-12 자 닉네임"), PayloadDocumentation.fieldWithPath("birthday").description("생년월일") ))); verify(signupService, times(1)).signup(any(SignupRequest.class)); }
Java
복사

로그인

package com.planner.travel.user.login; import com.fasterxml.jackson.databind.ObjectMapper; import com.planner.travel.domain.user.controller.UserController; import com.planner.travel.domain.user.dto.request.LoginRequest; import com.planner.travel.domain.user.service.LoginService; import com.planner.travel.domain.user.service.SignupService; import com.planner.travel.global.ApiDocumentUtil; import com.planner.travel.global.jwt.token.TokenGenerator; import com.planner.travel.global.jwt.token.TokenType; import com.planner.travel.global.util.CookieUtil; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; import org.springframework.restdocs.payload.PayloadDocumentation; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(UserController.class) @AutoConfigureRestDocs @WithMockUser(username="wldsmtldsm65@gmail.com", roles={"USER"}) public class LoginControllerTest { @Autowired private MockMvc mockMvc; @MockBean private SignupService signupService; @MockBean private LoginService loginService; @MockBean private CookieUtil cookieUtil; @MockBean private TokenGenerator tokenGenerator; @DisplayName("로그인") @Test void login() throws Exception { LoginRequest request = new LoginRequest("wldsmtldsm65@gmail.com", "123qwe!@#QWE"); String fakeAccessToken = "Bearer valid_access_token"; // 유효한 토큰으로 가정 String fakeRefreshToken = "valid_refresh_token"; // 유효한 토큰으로 가정 when(tokenGenerator.generateToken(TokenType.ACCESS, "1")).thenReturn(fakeAccessToken); when(tokenGenerator.generateToken(TokenType.REFRESH, "1")).thenReturn(fakeRefreshToken); doAnswer(invocation -> { HttpServletResponse response = invocation.getArgument(1); response.setHeader("Authorization", fakeAccessToken); Cookie cookie = new Cookie("refreshToken", fakeRefreshToken); cookie.setPath("/"); // 쿠키 경로 설정 cookie.setHttpOnly(true); // HttpOnly 설정 response.addCookie(cookie); return response; }).when(loginService).login(any(), any(HttpServletResponse.class)); MockHttpServletResponse response = mockMvc.perform(post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(new ObjectMapper().writeValueAsString(request)) .with(csrf())) .andExpect(status().isOk()) .andDo(MockMvcRestDocumentation.document("login", ApiDocumentUtil.getDocumentRequest(), ApiDocumentUtil.getDocumentResponse(), PayloadDocumentation.requestFields( PayloadDocumentation.fieldWithPath("email").description("이메일"), PayloadDocumentation.fieldWithPath("password").description("영어, 숫자, 특수 문자 포함 8 - 20 자리 비밀번호") ), responseHeaders( headerWithName("Authorization").description("Access token 을 포함한 헤더 입니다."), headerWithName("Set-Cookie").description("Refresh token 을 포함한 헤더 입니다.") ))) .andReturn().getResponse(); assertEquals(fakeAccessToken, response.getHeader("Authorization")); assertTrue(response.getCookies()[0].getValue().contains(fakeRefreshToken)); } }
Java
복사

@Valid 로 값을 검증하는 경우

이미 존재하는 이메일을 가지고 회원가입을 하는 경우를 생각 해봅시다. (이때 다른 요청 값은 다 패턴을 벗어나지 않는다고 가정합니다.) 이 경우 @Valid 를 어긋나는 요청 값이 없기 때문에 서비스가 한번 수행될 것입니다.
@DisplayName("존재 하는 이메일 검증") @Test void validateExistEmail() throws Exception { SignupRequest request = new SignupRequest( "wldsmtldsm65@gmail.com", "123qwe!@#QWE", "시니", LocalDate.parse("1996-11-20") ); doThrow(new IllegalArgumentException()) .when(signupService) .signup(any(SignupRequest.class)); mockMvc .perform(post("/api/v1/auth/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()) .andExpect(result -> assertInstanceOf(IllegalArgumentException.class, result.getResolvedException())) .andDo(MockMvcRestDocumentation.document("signup-existent-email", ApiDocumentUtil.getDocumentRequest(), ApiDocumentUtil.getDocumentResponse(), PayloadDocumentation.requestFields( PayloadDocumentation.fieldWithPath("email").description("이메일"), PayloadDocumentation.fieldWithPath("password").description("영어, 숫자, 특수 문자 포함 8 - 20 자리 비밀번호"), PayloadDocumentation.fieldWithPath("nickname").description("특수 문자를 포함 하지 않는 2-12 자 닉네임"), PayloadDocumentation.fieldWithPath("birthday").description("생년월일") ))); verify(signupService, times(1)).signup(any(SignupRequest.class)); }
Java
복사
하지만 지정 해둔 패턴을 벗어나 @Valid 과정에서 MethodArgumentNotValidException 이 발생하는 경우는 어떻게 될까요? 서비스는 한번도 실행되지 못합니다. 예외가 발생하고 끝나버리죠. verify() 를 사용하여 테스트 코드를 검증할 때 이를 주의해주세요.
@DisplayName("닉네임 양식 검증") @Test // a(길이 미달), 시니Aaaaaaaaaaaaaa(길이 초과), 시니😧(특수 문자 사용) void validateNickname() throws Exception { SignupRequest request = new SignupRequest( "wldsmtldsm65@gmail.com", "123qwe!@#QWE", "[시니]", // 특수 문자 사용 LocalDate.parse("1996-11-20") ); doNothing() .when(signupService) .signup(any(SignupRequest.class)); mockMvc .perform(post("/api/v1/auth/signup") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isBadRequest()) .andExpect(result -> assertInstanceOf(MethodArgumentNotValidException.class, result.getResolvedException())) .andDo(MockMvcRestDocumentation.document("signup-invalid-nickname", ApiDocumentUtil.getDocumentRequest(), ApiDocumentUtil.getDocumentResponse(), PayloadDocumentation.requestFields( PayloadDocumentation.fieldWithPath("email").description("이메일"), PayloadDocumentation.fieldWithPath("password").description("영어, 숫자, 특수 문자 포함 8 - 20 자리 비밀번호"), PayloadDocumentation.fieldWithPath("nickname").description("특수 문자를 포함 하지 않는 2-12 자 닉네임"), PayloadDocumentation.fieldWithPath("birthday").description("생년월일") ))); verify(signupService, never()).signup(any(SignupRequest.class)); }
Java
복사

특정 예외가 잘 나오는지 확인하고 싶다면?

원하는 예외는 어디에 위치하는가?

이전에 Filter 에 따른 Exception Custom - EntryPoint 란? 글을 작성한 적이 있습니다. 예외가 어디서 발생 하는지에 따라 처리를 하는 방식이 다릅니다. 간단하게 말하면, 시큐리티 필터를 넘어가기 전가지의 오류는 AuthenticationEntryPoint 를 사용하여, 그 이후 서비스 로직 등에서 나타난 오류는 @ControllerAdvice 와 @ExceptionHandler 를 사용하여 처리합니다. 저는 여기서 인증 예외에 관한 이야기를 더 해보고자 합니다.

@WebMvcTest 가 가져오는 Security Configuration 은…

제목 그대로 @WebMvcTest 가 가져오는 Security Configuration 은 기본 설정 입니다. 따라서 테스트 실행시 아래와 같은 문구를 확인할 수 있죠.
2024-04-21T18:42:53.552+09:00 DEBUG 7992 --- [ main] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext 2024-04-21T18:42:53.554+09:00 DEBUG 7992 --- [ main] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@75eaba95, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]] 2024-04-21T18:42:53.554+09:00 DEBUG 7992 --- [ main] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using Or [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest], And [Not [MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@75eaba95, matchingMediaTypes=[text/html], useEquals=false, ignoredMediaTypes=[]]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@75eaba95, matchingMediaTypes=[application/atom+xml, application/x-www-form-urlencoded, application/json, application/octet-stream, application/xml, multipart/form-data, text/xml], useEquals=false, ignoredMediaTypes=[*/*]]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.ContentNegotiationManager@75eaba95, matchingMediaTypes=[*/*], useEquals=true, ignoredMediaTypes=[]]] 2024-04-21T18:42:53.555+09:00 DEBUG 7992 --- [ main] s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint@34ab26a 2024-04-21T18:42:53.555+09:00 DEBUG 7992 --- [ main] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest] 2024-04-21T18:42:53.555+09:00 DEBUG 7992 --- [ main] s.w.a.DelegatingAuthenticationEntryPoint : No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@5891b7c5
Plain Text
복사
제가 설정한 AuthenticationEntryPoint 를 찾지 못해 기본 AuthenticationEntryPoint 를 쓰겠다는 소리 입니다. 이는 당연한게 커스텀한 AuthenticationEntryPoint 는 제가 따로 생성한 Security configuration 에 등록하는데, 가져오는 configuration 자체가 기본 설정이기 때문이죠.

UsernameNotFoundException()

존재하지 않는 이메일로 로그인을 시도했을 때 기본적으로 401 코드를 반환합니다. 하지만 저는 조금 더 명확함을 원해 404 코드를 반환하도록 아래와 같이 작성했습니다.
@Component @RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { if (authException instanceof UsernameNotFoundException) { response.setStatus(HttpStatus.NOT_FOUND.value()); setResponse(response, "AUTH_01");
Java
복사
하지만 테스트 수행결과 아래와 같은 결과가 나왔죠.
MockHttpServletResponse: Status = 401 Error message = Unauthorized Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", WWW-Authenticate:"Basic realm="Realm"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"] Content type = null Body = Forwarded URL = null Redirected URL = null Cookies = []
Plain Text
복사
그렇다면 실제 환경에서는 어떨까 테스트를 해보았습니다. 그리고 원하는 결과가 잘 나오는 것을 확인할 수 있었습니다.
HTTP/1.1 400 Vary: Origin Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers X-Content-Type-Options: nosniff X-XSS-Protection: 0 Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Content-Type: application/json;charset=UTF-8 Content-Length: 26 Date: Sun, 21 Apr 2024 09:59:15 GMT Connection: close { "errorCode": "AUTH_02" }
Plain Text
복사

@SpringBootTest 의 사용

이미 존재 하는 유저와의 값을 비교해야 하기도 하고, 시큐리티 설정등 최대한 운영 환경과 비슷한 환경에서 테스트를 하기 위해 @SpringBootTest 를 사용하기로 결정하였습니다. static 폴더 아래 테스트 전용 yml 을 생성해주었습니다.
src/test/resources 디렉토리에 application.yml 을 넣어주면 테스트 실행 시 자동으로 로드되며, 기본 application.yml 보다 우선적으로 적용이 됩니다. 저의 경우 test database 의 경우 H2 를 사용하였습니다. (나중에 1차 개발이 완료되면 데이터 서버를 팔 계획 입니다.)
spring: main: allow-bean-definition-overriding: true jpa: database-platform: org.hibernate.dialect.H2Dialect show-sql: true hibernate: ddl-auto: update datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driverClassName: org.h2.Driver username: sa password: data: redis: port: 6379 host: 127.0.0.1 springdoc: version: v1.0.0 default-consumes-media-type: application/json;charset=UTF-8 secret: key: ${SECRET} servlet: multipart: max-file-size: 10MB max-request-size: 10MB logging: level: root: info org: springframework: security: debug jdbc: core: debug web: debug
Plain Text
복사
공개 되어서는 안되는 key 의 경우 환경 변수로 설정하여 따로 관리해주세요. 해당 변수는 test configuration 설정 시 넣어주면 됩니다.
그후 아래와 같이 코드를 작성하고 실행하면 테스트에 성공함을 알 수 있습니다.
package com.planner.travel.user.login; import com.fasterxml.jackson.databind.ObjectMapper; import com.planner.travel.domain.user.dto.request.LoginRequest; import com.planner.travel.domain.user.entity.User; import com.planner.travel.domain.user.repository.UserRepository; import com.planner.travel.domain.user.service.LoginService; import com.planner.travel.global.ApiDocumentUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; import org.springframework.restdocs.payload.PayloadDocumentation; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import java.time.LocalDate; import java.time.LocalDateTime; @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs public class LoginAuthenticationTest { @Autowired MockMvc mockMvc; @Autowired LoginService loginService; @Autowired private ObjectMapper objectMapper; @Autowired UserRepository userRepository; @BeforeEach void setUp() { userRepository.deleteAll(); User user = User.builder() .email("wldsmtldsm65@gmail.com") .password("123qwe!#QWE") .nickname("시은") .isWithdrawal(false) .birthday(LocalDate.parse("1996-11-20")) .signupDate(LocalDateTime.now()) .userTag(1234L) .build(); userRepository.save(user); } @Test @DisplayName("존재 하지 않는 이메일을 입력한 경우") void loginWithWrongEmail() throws Exception { LoginRequest request = new LoginRequest( "suminnnn@gmail.com", "123qwe!@#QWE" ); mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(MockMvcResultMatchers.status().isNotFound()) .andExpect(MockMvcResultMatchers.jsonPath("$.errorCode").value("AUTH_01")) .andDo(MockMvcRestDocumentation.document("login-notexistent-email", ApiDocumentUtil.getDocumentRequest(), ApiDocumentUtil.getDocumentResponse(), PayloadDocumentation.requestFields( PayloadDocumentation.fieldWithPath("email").description("이메일"), PayloadDocumentation.fieldWithPath("password").description("영어, 숫자, 특수 문자 포함 8 - 20 자리 비밀번호") ) )); } @Test @DisplayName("잘못된 비밀번호를 입력하여 로그인") void loginWithWrongPassword() throws Exception { LoginRequest request = new LoginRequest( "wldsmtldsm65@gmail.com", "123qwe!@#QWE1" ); mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(MockMvcResultMatchers.status().isBadRequest()) .andExpect(MockMvcResultMatchers.jsonPath("$.errorCode").value("AUTH_02")) .andDo(MockMvcRestDocumentation.document("login-wrong-password", ApiDocumentUtil.getDocumentRequest(), ApiDocumentUtil.getDocumentResponse(), PayloadDocumentation.requestFields( PayloadDocumentation.fieldWithPath("email").description("이메일"), PayloadDocumentation.fieldWithPath("password").description("영어, 숫자, 특수 문자 포함 8 - 20 자리 비밀번호") ) )); } }
Java
복사