배경
현재 제가 진행 중인 프로젝트 Test 상황입니다.
Persistence Layer와 Business Layer에서 스프링을 통으로 띄워서 통합테스트를 진행했습니다. 그리고 기존 Presentation Layer 테스트를 작성할 때 같은 환경에서 테스트를 진행했습니다.
이러한 방식은 서버를 띄우는 시간과 사용 중인 Bean을 모두 불러오고 중복된 테스트 작성이 발생하기 때문에 시간이 오래 걸리고 비효율적인 테스트 작성입니다.
Presentation Layer 테스트 범위에 대한 생각과 리팩토링 과정을 작성하겠습니다.
Presentation Layer에서 무엇을 테스트할까?
진행 방식
Presentation Layer를 테스트 할 땐, Persistence Layer와 Business Layer를 Mocking처리 후, 테스트를 진행합니다.
이미 Persistence Layer와 Business Layer에 대한 테스트를 진행한 상태이기 때문에 잘 작동한다는 가정하에 Presentation Layer 단위 테스트 느낌으로 테스트에 집중하기 위함입니다.
Spring Web MVC
- 클라이언트가 url을 요청하면, 웹 브라우저에서 스프링으로 request가 보내진다.
- Dispatcher Servlet이 request를 받으면, Handler Mapping을 통해 해당 url을 담당하는 Controller를 탐색 후 찾아낸다.
- 찾아낸 Controller로 request를 보내주고, 보내주기 위해 필요한 Model을 구성한다.
Presentation Layer 테스트 범위
위와 같은 구조를 설명한 이유는 Controller 테스트 시에 Controller 내부의 코드 뿐만 아니라 filter부터 Handler 내부에 이르기까지 테스트 범위가 수행될 수 있기 때문입니다.
따라서 테스트 목적에 이를 구분하고 필요한 Bean들을 생성하여 범위를 나누어야 합니다.
Presentation Layer 테스트 내용
- 외부 세계의 요청을 가장 먼저 받는 계층
- 파라미터에 대한 최소한의 검증을 수행한다.
테스트 구현 방법
@WebMvcTest
Bean 생성 범위
@Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans).
@WebMvcTest(controllers = {.class})와 같이 특정 Controller만 로딩하여 테스트 할 수 있다.
리팩토링 과정
Before, UserController
기존 코드
기존 작성했던 UserController에 대한 내용입니다.
@ExtendWith({RestDocumentationExtension.class})
@SpringBootTest
@Transactional
class UserControllerTest {
@Autowired
private WebApplicationContext context;
private MockMvc mvc;
@Autowired
UserService userService;
@Autowired
UserRepository userRepository;
@Autowired
CommentRepository commentRepository;
@Autowired
ItemRepository itemRepository;
private ObjectMapper objectMapper = new ObjectMapper();
@BeforeEach
void setUp(RestDocumentationContextProvider restDocumentation) {
userRepository.deleteAll();
mvc = MockMvcBuilders
.webAppContextSetup(context)
.apply(documentationConfiguration(restDocumentation)
.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint()))
.build();
}
@DisplayName("회원가입 API 테스트")
@Test
void createUser() throws Exception {
// given
UserCreateRequestDto createDto = UserCreateRequestDto.builder()
.username("아이디")
.password("비밀번호")
.email("이메일")
.userImage("사진")
.nickname("닉네임")
.phoneNumber("번호")
.address(new Address("city", "street", "zipcode"))
.build();
String url = "http://localhost:8080/join";
// when
ResultActions perform = mvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createDto)));
// then
perform.andExpect(status().isOk())
.andDo(document("/join",
requestFields(
fieldWithPath("username").description("계정"),
fieldWithPath("password").description("비밀번호"),
fieldWithPath("email").description("이메일"),
fieldWithPath("userImage").description("사진"),
fieldWithPath("nickname").description("닉네임"),
fieldWithPath("phoneNumber").description("번호"),
fieldWithPath("address.city").description("city"),
fieldWithPath("address.street").description("street"),
fieldWithPath("address.zipcode").description("zipcode")
)));
}
}
@SpringBootTest 사용하여 모든 Bean을 불러옵니다.
UserController에 대한 테스트를 진행하면서(RequestBody 값 검증), userService.createUser까지 테스트 합니다.
이러한 방식은 이미 Service Test를 통해 진행된 부분을 중복하여 테스트 하게 되는 것이고, 중복된 테스트라 필요 없는 테스트라고 생각합니다.
테스트 시간 측정
1개를 테스트 하는 데에 걸리는 시간입니다.
After, UserController
@WebMvcTest(controllers = UserController.class)
class UserControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@MockBean
protected UserService userService;
@DisplayName("회원가입 테스트 신규 회원을 등록한다.")
@Test
@WithMockUser
void createUser() throws Exception {
// given
UserCreateRequestDto request = UserCreateRequestDto.builder()
.username("아이디")
.password("비밀번호")
.build();
// when // then
mockMvc.perform(
post("/join").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.message").value("OK"));
}
@DisplayName("회원가입할 때 아이디는 꼭 입력해야 한다.")
@Test
@WithMockUser
void createUserWithEmptyUsername() throws Exception {
// given
UserCreateRequestDto request = UserCreateRequestDto.builder()
// .username("아이디")
.password("비밀번호")
.build();
// when // then
mockMvc.perform(
post("/join").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("아이디는 필수로 입력해야 됩니다."))
.andExpect(jsonPath("$.data").isEmpty());
}
@DisplayName("회원가입할 때 비밀번호는 꼭 입력해야 한다.")
@Test
@WithMockUser
void createUserWithEmptyPassword() throws Exception {
// given
UserCreateRequestDto request = UserCreateRequestDto.builder()
.username("아이디")
// .password("비밀번호")
.build();
// when // then
mockMvc.perform(
post("/join").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("비밀번호는 필수로 입력해야 됩니다."))
.andExpect(jsonPath("$.data").isEmpty());
}
}
테스트 시간 측정
구체적인 조건을 설정하여 명확한 테스트 결과를 얻을 수 있습니다. - Valid값 검증
모든 Bean을 불러오는 것이 아니기 때문에 테스트 시간을 최소화할 수 있습니다.
마치며
테스트 코드를 리팩토링 하면서 기존에 작성했던 테스트들이 효율적이기 못하다는 것을 학습했습니다.
각 계층마다 테스트하는 범위가 명확하지 않았고, 통합 환경을 구성하지 않았기 때문에 서버가 불필요하게 많이 뜨는 현상도 겪었습니다.
이번 계기로 Controller, Service, Repository에 대한 테스트 환경 분리 및 통합 과정을 통해 시간을 줄일 수 있었고, 계층마다 어떤 테스트가 필요한 지 제대로 학습하고 적용할 수 있었습니다.
'Spring' 카테고리의 다른 글
Scale-out 상황, 채팅 서비스 Redis 도입 (0) | 2024.06.20 |
---|---|
좌표값(객체) 테스트에 대한 고민 (0) | 2023.11.25 |
CSRF(Cross-Site Request Forgery)란? (1) | 2023.10.19 |
LocalStorage, Cookie JWT토큰은 어디에 보관할까? (0) | 2023.10.17 |
XSS(Cross Site Scripting) 공격 (0) | 2023.10.16 |