📕 목차
1. Annotation
2. Test planning
3. Test case implementation
1. Annotation
💡 개념 설명은 잘 되어 있는 곳이 너무 많아서, 사용 방법에 대해 보다 상세하게 서술했습니다.
📌 @SpringBootTest
- 통합 테스트를 위한 어노테이션
- 그야 말로 만능. 하지만 만능이기 위해서 모든 Bean을 등록시켜야 하기 때문에 느리다.
- Test는 빨라야 한다. 그렇지 않으면 테스트 케이스의 이점을 가질 수 없다.
- 불필요한 데이터 생성이 많고, 테스트 대상 범위가 넓어져 실제 테스트 대상에 집중이 어렵다.
(Controller만 테스트 하려는데, Service나 Repository까지 신경써야 한다.) - 단위 테스를 위함이라면 @SpringBootTest를 사용할 이유가 없다.
📌 @WebMvcTest
- Application Context를 완전하게 불러오지 않고, Web layer에 속하는 Bean을 테스트할 때 사용한다.
- @Controller, @ControllerAdvice, @JsonComponent에 해당하는 클래스만 스캔하도록 제한된다.
- 의존성 주입이 필요한 Service나 별도 Bean은 MockBean을 사용한다.
- 이 외에도 Converter, GenericConverter, Filter, HandlerInterceptor, WebMvcConfigurer, HandlerMethodArgumentResolver를 스캔한다.
📌 @MockBean
- UserController에 UserService 주입이 필요하다면 해당 Service의 껍데기만을 불러온다.
- 마치 인터페이스나 추상메서드처럼 메서드 정의만 존재하고, 내부 구현 부분은 모두 사용자에게 위임한다.
- 즉, 개발자 필요에 의해서 input에 대한 output 값을 조작할 수 있다.
- 조작할 때는 given() 함수를 사용한다.
📌 @SpyBean
설명 진짜 친절하게 잘 되어 있다. 읽어보면 매우 좋은 내용.
- @Mock와 달리 given()에서 선언한 코드 외에는 전부 실체 객체의 것을 사용한다.
2. Test planning
📌 무엇을 테스트할 것인가?
테스트를 하기 전에 목적을 확실히 해야 한다.
그렇지 않으면 너무 방대해지거나, 의미없는 테스트 코드가 우후죽순 발생하게 된다.
@Slf4j
@Tag(name = "[인증 API]")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthUseCase authUseCase;
private final CookieUtil cookieUtil;
@Operation(summary = "일반 회원가입")
@PostMapping("/sign-up")
// TODO: Spring Security 설정 후 @PreAuthorize("isAnonymous()") 추가
public ResponseEntity<?> signUp(@RequestBody @Validated SignUpReq.General request) {
Pair<Long, Jwts> jwts = authUseCase.signUp(request);
ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.header(HttpHeaders.AUTHORIZATION, jwts.getValue().accessToken())
.body(SuccessResponse.from("user", Map.of("id", jwts.getKey())))
;
}
}
내가 테스트하고 싶은 것은 다음과 같다.
- AuthController의 signUp 요청에서 Validation이 수행되고 있는가?
- Validation의 응답이 적절한 형태의 Json으로 반환되고 있는가?
- AuthControler의 signUp 요청이 성공했을 때, 적절한 형태의 Json으로 반환되고 있는가?
validation이 실패했을 때는 다음과 같은 응답 포맷을 갖는다.
{
"code": "4220",
"message": "reason",
"fieldErrors": {
"fieldName1": "reason1",
...
}
}
모든 요청이 성공했을 때는 다음과 같은 응답 포맷을 갖는다.
// Header: Set-Cookie {쿠키 정보}
// status 200_OK
{
"code": "2000",
"data": {
"user": {
"id": 1 // 회원가입한 user pk
}
}
}
🟡 가정
- Service Layer의 로직은 언제나 요청에 대한 예상 응답을 반환한다.
📌 어떤 Bean이 필요한가?
1️⃣ @WebMvcTest
@WebMvcTest(controllers = {AuthController.class})
- @WebMvcTest만을 사용하면 테스트하려는 클래스와 완전히 동일한 패키지에 위치해야 한다고 한다.
- 그런 강제 방법이 테스트 코드를 체계적으로 관리할 수는 있겠으나, 너무 main 패키지에 의존적인 것 같아 직접 테스트할 컨트롤러를 명시했다.
2️⃣ MockMvc
- 애플리케이션을 가동하지 않아도 서버의 MVC 동작을 테스트할 수 있다.
3️⃣ ObjectMapper
- 객체를 json 형태로 파싱하기 위해 주입한다.
- 모든 테스트 마다 new ObjectMapper를 하기엔 ObjectMapper의 생성 비용이 만만치 않다.
- @Autowired로 테스트 클래스에 주입시키는 게 차라리 나을 것이다. (뇌피셜)
4️⃣ @MockBean
- AuthController는 AuthUseCase와 CookieUtil 빈을 주입받고 있다.
- 이대로 실행하면 의존성 주입에 실패하여 테스트가 실행되지 않는다.
- Service 로직의 실패에 대한 테스트 검증은 관심사가 아니므로 가짜 객체를 주입하도록 한다.
📌 테스트 클래스 초기 설정
@WebMvcTest(controllers = {AuthController.class})
@ActiveProfiles("local")
public class AuthControllerValidationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private AuthUseCase authUseCase;
@MockBean
private CookieUtil cookieUtil;
@DisplayName("[1] 아이디, 이름, 비밀번호, 전화번호, 인증번호 필수 입력")
@Test
void requiredInputError() throws Exception {
}
@DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")
@Test
void idValidError() throws Exception {
}
@DisplayName("[3] 이름은 2~20자의 한글, 영문 대/소문자만 사용 가능합니다.")
@Test
void nameValidError() throws Exception {
}
@DisplayName("[4] 비밀번호는 8~16자의 영문 대/소문자, 숫자, 특수문자를 사용해주세요. (적어도 하나의 영문 소문자, 숫자 포함)")
@Test
void passwordValidError() throws Exception {
}
@DisplayName("[5] 전화번호는 010 혹은 011로 시작하는, 010-0000-0000 형식이어야 합니다.")
@Test
void phoneValidError() throws Exception {
}
@DisplayName("[6] 인증번호는 6자리 숫자여야 합니다.")
@Test
void codeValidError() throws Exception {
}
@DisplayName("[7] 일부 필드 누락")
@Test
void someFieldMissingError() throws Exception {
}
@DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환")
@Test
void signUp() throws Exception {
}
}
- @ActiveProfiles를 설정했는데, 해당 컨트롤러는 딱히 프로필 환경에 의존하는 항목이 없어서 사실 필요없다.
- 다른 문제 해결하다가 추가해놨는데 있으나, 없으나 상관 없어서 놔뒀다가 제외하는 걸 잊어먹었다.
- 다만 test 환경의 profile을 추가 구성하는 게 필요할 수도 있을 것 같다는 걸 인지하게 되어, 여기도 지우지 않고 올리게 되었다.
3. Test case implementation
📌 given
우선 아이디의 유효성 검사가 성공적으로 처리되는지 확인해보자.
@DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")
@Test
void idValidError() throws Exception {
// given
SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456");
// when
// then
}
- test를 위한 pre conditional 환경을 구성한다.
- 해당 테스트를 위해서는 사용자의 요청에 해당하는 request instance만 존재하면 된다.
📌 when
@DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")
@Test
void idValidError() throws Exception {
// given
SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456");
// when
ResultActions resultActions = mockMvc.perform(
post("/api/v1/auth/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// then
}
- mockMvc를 통해 해당 경로로 요청을 보낸다.
- 이때 요청에 필요한 header를 설정해줄 수 있다.
📌 then
@DisplayName("[2] 아이디는 5~20자의 영문 소문자, -, _, . 만 사용 가능합니다.")
@Test
void idValidError() throws Exception {
// given
SignUpReq.General request = new SignUpReq.General("#pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456");
// when
ResultActions resultActions = mockMvc.perform(
post("/api/v1/auth/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// then
resultActions
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.fieldErrors.username").value("5~20자의 영문 소문자, -, _, . 만 사용 가능합니다."))
.andDo(print());
}
// status : 422_Unprocessable_entity
{
"code": "4220",
"message": "UNPROCESSABLE_CONTENT",
"fieldErros": {
"username": "5~20자의 영문 소문자, -, _, . 만 사용 가능합니다."
}
}
- andExpect(): 응답의 예상값을 작성하여 검증한다.
- jsonPath(): 응답 필드 항목의 존재여부(exist())나 값(value())의 예상값을 정할 수 있다.
- andDo(print()): 요청과 응답 정보를 모두 출력한다.
🟡 print() 실행 결과
몇 가지 검증을 빠트린 부분도 존재하긴 하지만, 내가 원하는 응답 포맷이 반환되는 것을 확인할 수 있다.
매번 애플리케이션 빌드해서 postman으로 하나하나 쏴보던 노가다 시절에서 벗어날 수 있게 되었다!!
또한 테스트 케이스를 관리해두면 수정 사항이 발생해도 기존의 요청이 언제나 성공함을 보증할 수 있어
유지 보수 관점에서 굉장한 이점을 얻을 수 있다.
📌 성공 응답 테스트 하기
validation check는 사실상 controller 내부 로직에 닿기도 전에 유효성 검사로 예외가 발생해버린다.
그렇다면 성공적인 요청이 들어갔을 때는 service 메서드를 거쳐야 하는데, 테스트 케이스를 어떻게 작성해야 할까?
@Slf4j
@Tag(name = "[인증 API]")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthUseCase authUseCase;
private final CookieUtil cookieUtil;
@Operation(summary = "일반 회원가입")
@PostMapping("/sign-up")
// TODO: Spring Security 설정 후 @PreAuthorize("isAnonymous()") 추가
public ResponseEntity<?> signUp(@RequestBody @Validated SignUpReq.General request) {
Pair<Long, Jwts> jwts = authUseCase.signUp(request);
ResponseCookie cookie = cookieUtil.createCookie("refreshToken", jwts.getValue().refreshToken(), Duration.ofDays(7).toSeconds());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.header(HttpHeaders.AUTHORIZATION, jwts.getValue().accessToken())
.body(SuccessResponse.from("user", Map.of("id", jwts.getKey())))
;
}
}
- authUseCase.signup(request)이 성공 응답을 반환했다고 가정하고 응답값을 조작한다.
- cookieUtil.createCookie() 또한 성공했다 치고 응답값을 조작해야 한다.
1️⃣ given
@DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환")
@Test
void signUp() throws Exception {
// given
SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456");
ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken").maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build();
given(authUseCase.signUp(request))
.willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken")));
given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds()))
.willReturn(expectedCookie);
// when
// then
}
- given 구문에서 반드시 성공하는 요청 dto를 작성한다.
- org.mockito.BDDMockito.given() 함수를 사용하여 예상 응답값을 조작한다.
- authUseCase.signup()의 응답값은 Pair{1L, Jwts{"accessToken", "refreshToken"}}이다.
- cookeUtil.createCookie()의 응답값은 expectedCookie 인스턴스다.
2️⃣ when
@DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환")
@Test
void signUp() throws Exception {
// given
SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456");
ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken").maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build();
given(authUseCase.signUp(request))
.willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken")));
given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds()))
.willReturn(expectedCookie);
// when
ResultActions resultActions = mockMvc.perform(
post("/api/v1/auth/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// then
}
- 마찬가지로 mockMvc를 통해 요청을 보낸다.
3️⃣ then
@DisplayName("[8] 정상적인 회원가입 요청 - 쿠키/인증 헤더와 회원 pk 반환")
@Test
void signUp() throws Exception {
// given
SignUpReq.General request = new SignUpReq.General("pennyway", "페니웨이", "pennyway1234", "010-1234-5678", "123456");
ResponseCookie expectedCookie = ResponseCookie.from("refreshToken", "refreshToken").maxAge(Duration.ofDays(7).toSeconds()).httpOnly(true).path("/").build();
given(authUseCase.signUp(request))
.willReturn(Pair.of(1L, Jwts.of("accessToken", "refreshToken")));
given(cookieUtil.createCookie("refreshToken", "refreshToken", Duration.ofDays(7).toSeconds()))
.willReturn(expectedCookie);
// when
ResultActions resultActions = mockMvc.perform(
post("/api/v1/auth/sign-up")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
);
// then
resultActions
.andExpect(status().isOk())
.andExpect(header().string("Set-Cookie", expectedCookie.toString()))
.andExpect(header().string("Authorization", "accessToken"))
.andExpect(jsonPath("$.data.user.id").value(1))
.andDo(print());
}
- 응답 status는 200_OK여야 한다.
- Set-Cookie 헤더는 expectedCookie와 동일한 정보가 담겨야 한다.
- Authorization 헤더는 "accessToken" 문자열이 담겨있어야 한다.
- body의 data.user.id에는 조작한 응답의 유저 pk인 1이 담겨있어야 한다.
📌 무엇을 더 테스트 해야 할까?
Web Layer 테스트 방법만을 다루기 위한 포스팅이므로 자세하게는 다루지 않겠지만,
직접 테스트 케이스를 작성해보면 정말 예외적인 부분이 많다는 것을 알 수 있다.
예를 들어, 아이디의 유효성 검사 하나만 해도 "5~20자의 영문 소문자, -, _, .만 사용 가능합니다."를 확인하기 위해 정말 온갖 종류의 문자열을 넣어봐야 한다.
이를 각 테스트 별로 구분할지, 혹은 하나의 메서드에서 given-when-then을 꽉꽉 채워서 실행해볼지 고민해봐야 한다.
(하지만 TDD는 간결해야 한다는 원칙 또한 존재한다. id 유효성 검사를 위해 하나의 메서드에서 10가지 문자열에 대한 테스트를 실행하면 다른 개발자가 해당 테스트가 확인하고자 하는 바를 바로 확인하지 못할 수도 있다.)
이 부분에 대해서도 이야기해보고 싶지만 아직 식견이 좁아서 더 많은 Test case를 작성해보면서 연구해보고자 한다.