회원가입 컨트롤러 테스트(Controller Test) - RequestDto @NotBlank 필드 검증
회원가입의 요청을 담아주는 SignUpRequestDto를 작성할때 필수값 누락을 방지하기 위해 @NotBlank 어노테이션을 사용했다. 이때 필수값이 누락되었을때 내가 정의한 에러 반환값이 잘 반환되는지 회원가입 Controller Test를 진행해보겠다.
1. 회원가입 API 구현: 데이터 유효성 검사
SignUpRequestDto 클래스는 회원가입 시 클라이언트로부터 받는 데이터를 정의한 Request Dto다. 이메일, 비밀번호, 전체 이름 등은 회원가입 과정에서 반드시 필요한 데이터이므로 @NotBlank를 사용하여 유효성 검사를 강화했다.
@NotBlank는 null, 빈 문자열(""), 공백 문자열(" ") 모두 허용하지 않는다. 이는 @NotNull과 @NotEmpty와는 다른데, @NotNull은 null만 허용하지 않고, @NotEmpty는 null과 빈 문자열("")을 허용하지 않는다.
@NotBlank는 이 두 조건에 공백 문자열(" ")도 포함하여 가장 강력한 유효성 검사를 제공한다.
이러한 정보는 DTO에서 필수 필드에 대한 유효성 검사를 강화하는 데 유용하며, 필수 필드가 비어있지 않고 적절한 값을 포함하고 있는지 확인하는 데 도움이 된다. 이로써 더 엄격한 데이터 검증을 통해 시스템의 안정성을 높일 수 있다.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class SignUpRequestDto {
@NotBlank
@Size(max = 50)
private String email; // 이메일
@NotBlank
@Size(max = 20)
private String password; // 회원 비밀번호
@NotBlank
@Size(max = 20)
private String fullName; // 회원이름
@NotBlank
@Size(max = 20)
private String nickname; // 닉네임
private String introduction; // 한줄소개
@NotBlank
@Size(max = 25)
private String telNo; // 전화번호
private String address1; // 주소1
private String address2; // 주소2
@NotBlank
@Size(max = 1)
private String collectionConsentYn; // 개인정보 수집 및 이용 동의 여부
@NotBlank
@Size(max = 1)
private String marketingConsentYn; // 마케팅 활용 동의 여부
@NotBlank
@Size(max = 1)
private String privacyPolicyConsentYn; // 개인정보 보호 정책 동의 여부
@NotBlank
@Size(max = 1)
private String cookieConsentYn; // 쿠키 및 추적 기술 사용 동의 여부
// private 생성자
private SignUpRequestDto(String email, String password, String fullName, String nickname, String introduction, String telNo, String address1, String address2, String collectionConsentYn, String marketingConsentYn, String privacyPolicyConsentYn, String cookieConsentYn) {
this.email = email;
this.password = password;
this.fullName = fullName;
this.nickname = nickname;
this.introduction = introduction;
this.telNo = telNo;
this.address1 = address1;
this.address2 = address2;
this.collectionConsentYn = collectionConsentYn;
this.marketingConsentYn = marketingConsentYn;
this.privacyPolicyConsentYn = privacyPolicyConsentYn;
this.cookieConsentYn = cookieConsentYn;
}
// 정적 팩토리 메소드
public static SignUpRequestDto of(String email, String password, String fullName, String nickname, String introduction, String telNo, String address1, String address2, String collectionConsentYn, String marketingConsentYn, String privacyPolicyConsentYn, String cookieConsentYn) {
return new SignUpRequestDto(email, password, fullName, nickname, introduction, telNo, address1, address2, collectionConsentYn, marketingConsentYn, privacyPolicyConsentYn, cookieConsentYn);
}
}
SignUpController 클래스에서 @Valid 어노테이션을 사용하여 SignUpRequestDto의 유효성 검사를 활성화한다. 이것은 클라이언트로부터 받은 요청이 처리되기 전에 Spring Framework가 SignUpRequestDto의 유효성을 검증하도록 하는것이다. @RequestBody 어노테이션은 HTTP 요청 본문을 객체로 변환하는 역할을 한다.
이렇게 함으로써, 요청 데이터가 컨트롤러 메소드에 도달하기 전에 유효성 검사가 수행되어, 데이터의 정확성을 보장할 수 있다.
@RequestMapping("/member")
@RestController
public class SignUpController {
@PostMapping("/signUp")
public ResponseEntity<ResponseDto<Long>> signUp(@Valid @RequestBody SignUpRequestDto requestDto) {
return ResponseEntity.ok(
ResponseDto.success(1L)
);
}
}
🔥 이해를 돕기 위해 추가하자면 ResponseDto.success 메소드는 아래와 같이 선언했다. 🔥
public static <T> ResponseDto<T> success(T result) {
return new ResponseDto<>("SUCCESS", result);
}
@RequestBody를 사용할 때 클래스 필드가 private으로 선언되어 있어도, NoArgsConstructor 어노테이션이 제공하는 기본 생성자를 통해 Spring이 객체를 인스턴스화하고, 리플렉션을 사용하여 필드를 채운다. 이는 개발자가 직접 파싱 로직을 작성할 필요 없이, 요청 데이터를 쉽게 객체로 변환할 수 있게 해준다.
MethodArgumentNotValidException은 @Valid 어노테이션이 적용된 객체의 유효성 검증이 실패했을 때 발생한다. 이 예외는 GlobalControllerAdvice 클래스에서 @ExceptionHandler 어노테이션을 사용하여 구현했다. 누락된 필드명을 포함한 BadRequest 응답을 반환한다. (나는 기존에 중앙 에러처리를 이용했기 때문에 내가 만든 GlobalControllerAdvice 클래스에 에러코드를 정의했다.)
/**
* MethodArgumentNotValidException 처리
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
List<String> missingFields = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getField)
.collect(Collectors.toList());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(missingFields);
}
1. 예외 캐치: @ExceptionHandler(MethodArgumentNotValidException.class)
이 부분은 Spring에게 MethodArgumentNotValidException 타입의 예외가 발생했을 때 이 메서드를 실행하라고 지시하는 내용이다. 즉, 유효성 검증에서 문제가 발생하면, 이 메서드가 해당 예외를 처리한다.
2. 누락된 필드 목록 생성: e.getBindingResult().getFieldErrors()
e.getBindingResult().getFieldErrors() 호출은 예외에 포함된 바인딩 결과에서 필드 에러들을 가져온다. 필드 에러는 유효성 검증에서 실패한 필드들에 대한 정보를 담고 있다.
에러 정보는 FieldError 객체의 리스트 형태로 반환된다.
3. 필드명 추출 및 리스트화: .stream().map(FieldError::getField).collect(Collectors.toList())
stream() 메서드를 사용해 필드 에러들의 스트림을 생성한다.
map(FieldError::getField)는 각 FieldError 객체에서 필드명만 추출한다.
collect(Collectors.toList())는 추출된 필드명들을 리스트로 변환한다. 이 리스트에는 유효성 검증에 실패한 필드의 이름들이 포함된다.
4. 클라이언트에게 응답 반환: ResponseEntity.status(HttpStatus.BAD_REQUEST).body(missingFields)
ResponseEntity.status(HttpStatus.BAD_REQUEST)는 HTTP 상태 코드 400 (BadRequest)를 설정한다. 이는 클라이언트에게 요청이 잘못되었음을 알린다.
.body(missingFields)는 위에서 생성한 누락된 필드명 리스트를 응답 본문으로 설정한다.
결과적으로, 이 메서드는 유효성 검증 실패에 대한 상세 정보(누락된 필드 목록)를 담은 BadRequest 응답을 클라이언트에게 보낸다.
2. 회원가입 API 테스트
SignUpControllerTest 클래스에서는 정상적인 데이터를 사용하여 회원가입이 성공적으로 이루어지는지 테스트한다. 이 테스트는 MockMvc를 사용하여 HTTP POST 요청을 시뮬레이션하고, 요청이 성공적으로 처리되는지 확인한다.
이 테스트는 통합 테스트에 가까운 형태로 MockMvc를 사용하여 실제 HTTP 요청을 보내는 것이 아니라, Spring MVC 내부에서 HTTP 요청과 응답을 모의하는 방식을 이용해 컨트롤러에 보내고, 응답을 검증함으로써 컨트롤러, DTO, 유효성 검증, 예외 처리 등 여러 구성 요소의 상호작용을 테스트하고 있다.
테스트 진행 순서
1. SignUpRequestDto 객체를 생성하여 모든 필수 필드를 입력한다.
2. MockMvc를 이용하여 /member/signUp 엔드포인트로 POST 요청을 보내고, JSON 형식으로 SignUpRequestDto 객체를 전송한다.
3. 응답 상태 코드가 OK(200)인지 확인하여 요청 처리 성공 여부를 검증한다.
MockMvc는 Spring MVC의 디스패처 서블릿을 모의하여, 실제 HTTP 서버를 구동하지 않고도 컨트롤러의 HTTP 요청과 응답을 테스트할 수 있게 해준다.
이 도구를 사용함으로써, 다양한 HTTP 요청과 응답을 실제와 유사한 환경에서 테스트할 수 있으며, 이는 애플리케이션의 REST API를 검증하는 데 효과적이다. 또한, 이 방법은 테스트 실행 속도를 높이고, 테스트 환경을 단순화하는 장점을 제공한다.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.recipia.member.adapter.in.web.dto.SignUpRequestDto;
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.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ActiveProfiles("test")
@AutoConfigureMockMvc
@SpringBootTest
class SignUpControllerTest{
@Autowired
private MockMvc mockMvc;
private ObjectMapper objectMapper = new ObjectMapper();
@DisplayName("[happy] 필수 입력값이 전부 충족된 dto가 들어왔을때 회원가입 정상 작동한다")
@Test
void signUpDtoSuccess() throws Exception {
//given
SignUpRequestDto validRequest = SignUpRequestDto.of(
"user@example.com", "password123", "John Doe", "johndoe",
"Hello, I'm John", "010-1234-5678", "123 Main St", "Apt 101",
"Y", "Y", "Y", "Y"
);
//when & then
mockMvc.perform(post("/member/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(asJsonString(validRequest)))
.andExpect(status().isOk())
.andDo(print());
}
// JSON 문자열 변환을 위한 유틸리티 메서드
private String asJsonString(final Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
위 테스트를 실행하면 아래와 같이 성공한 모습을 볼 수 있다.
RequestDto의 모든 필수 필드값이 들어왔기때문에 Status = 200을 리턴했다. Response 객체 Body에 resultCode는 SUCCESS로 들어왔고 컨트롤러에 하드코딩으로 넣어준 1L이 result에 잘 담긴 모습을 확인 할 수 있다.
또한, 필수 필드가 누락된 요청을 보내어 MethodArgumentNotValidException이 올바르게 발생하는지 테스트한다. 이 테스트는 잘못된 요청에 대해 BadRequest 응답과 함께 누락된 필드명을 반환하는지 확인한다.
테스트 진행 순서
1. SignUpRequestDto 객체를 생성할 때 일부 필수 필드(예: email, fullName)를 누락한다.
2. MockMvc를 이용하여 동일한 요청을 보내고, JSON 형식으로 누락된 필드를 포함한 SignUpRequestDto 객체를 전송한다.
3. 응답 상태 코드가 BadRequest(400)인지 확인한다.
4. 응답 본문에 누락된 필드명이 포함되어 있는지 검증하여, 유효성 검증이 올바르게 작동하는지 확인한다.
@Test
@DisplayName("[bad] 필수 입력값이 누락된 dto가 들어왔을 때 BadRequest를 반환한다")
void testMissingRequiredFields() throws Exception {
//given
SignUpRequestDto invalidRequest = SignUpRequestDto.of(
null, "password123", null, "johndoe",
"Hello, I'm John", "010-1234-5678", "123 Main St", "Apt 101",
"Y", "Y", "Y", "Y"
);
//when & then
mockMvc.perform(post("/member/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(asJsonString(invalidRequest)))
.andExpect(status().isBadRequest())
.andDo(print())
.andExpect(result -> {
String responseString = result.getResponse().getContentAsString();
assertThat(responseString).contains("email");
assertThat(responseString).contains("fullName");
});
}
위 테스트를 실행하면 아래와 같이 성공한 모습을 볼 수 있다.
RequestDto에서 필수값이 누락되었기 때문에 Status = 400을 반환한다. 그리고 필수값이 누락되는 MethodArgumentNotValidException 에러가 발생했기 때문에 내가 정의한 에러코드에 따라 Body에 누락된 필드명들이 들어간 모습을 확인할 수 있다.
🔻 BCryptPasswordEncoder에 대해서 궁금하다면? 🔻
로그인 실패 문제 - BCryptPasswordEncoder의 Salt 값과 Matches 메소드 이해하기
이 포스트는 Team chillwave에서 사이드 프로젝트를 하던 중 적용했던 부분을 다시 공부하면서 기록한 것입니다.
시간이 괜찮다면 팀원 '개발자의 서랍'님의 블로그도 한번 봐주세요 :)
'Spring > Spring 테스트코드' 카테고리의 다른 글
스프링 테스트 코드에서 실제 호출 @SpyBean으로 확인하기 (3) | 2023.12.31 |
---|---|
스프링 테스트 코드: @MockBean과 @Mock의 차이 (+ @InjectMocks) (1) | 2023.12.31 |
테스트 코드의 가독성 향상 - BDD 방법론과 @DisplayName 어노테이션 활용 (2) | 2023.12.26 |
테스트 전략에서 단위 테스트와 통합 테스트의 차이점과 의존성 관리 (1) | 2023.12.25 |
헥사고날 아키텍처와 TDD로 가는 길 (1) - 레이어 별 단위 테스트, 통합 테스트 전략 선택 (4) | 2023.12.24 |