이번 포스팅에서는 Spring Boot에서 통합테스트하는 방법에 대해서 알아보려고 합니다.
테스트 코드의 중요성은 아무리 강조해도 부족합니다.
Code Link
목차
1.1 Dependency
1.2 application.yml
1.3 어플리케이션 코드
2.1 통합테스트 Base Class
2.2 MockMvc 설명
2.3 테스트 코드 팁
2.4 테스트 코드
1. 테스트 대상 어플리케이션 구축
필요한 전체 클래스입니다.
테스트할 대상 어플리케이션의 기능은 다음과 같습니다.
- 유저 생성
- 유저 전체 조회
- 하나의 유저 조회
1.1 Dependency
dependencies {
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa', version: '2.3.0.RELEASE'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
}
jpa
spring web
lombok
h2
boot starter test
위 의존성을 추가합니다.
1.2 application.yml
xspring
h2
console
path /h2-console
enabledtrue
datasource
url jdbc h2 mem testdb
driver-class-name org.h2.Driver
username sa
password
jpa
show-sqltrue
Jpa 설정 및 테스트 db(h2) 설정을 진행합니다.
1.3 어플리케이션 코드
UserEntity
xxxxxxxxxx
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
name = "user") (
access = AccessLevel.PROTECTED) (
public class UserEntity {
private Long userId;
private String userName;
public UserEntity(String userName) {
this.userName = userName;
}
}
UserEntity 입니다. userName 필드를 가지고 있습니다.
UserRepository
xxxxxxxxxx
public interface UserRepository extends JpaRepository<UserEntity, Long> {}
UserRepository입니다. spring data jpa를 사용하여 CRUD 연산은 별도로 구현할 필요가 없습니다.
UserService
xxxxxxxxxx
package com.galid.jpastudy;
import com.galid.jpastudy.dto.CreateUserRequest;
import com.galid.jpastudy.dto.CreateUserResponse;
import com.galid.jpastudy.dto.GetUserListResponse;
import com.galid.jpastudy.dto.UserDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.stream.Collectors;
public class UserService {
private final UserRepository userRepository;
public CreateUserResponse createUser(CreateUserRequest request) {
UserEntity newUser = UserEntity.builder()
.userName(request.getUserName())
.build();
Long userId = userRepository.save(newUser).getUserId();
return new CreateUserResponse(userId);
}
public GetUserListResponse getUserList() {
return new GetUserListResponse(userRepository.findAll().stream()
.map(user -> new UserDto(user.getUserName()))
.collect(Collectors.toList()));
}
public UserDto getUser(Long userId) {
UserEntity userEntity = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다."));
return new UserDto(userEntity.getUserName());
}
}
- createUser
- getUserList
- getUser
세가지 기능을 가지고 있습니다.
UserController
public class UserController {
private final UserService userService;
@PostMapping("/users")
public CreateUserResponse createUser(CreateUserRequest request) {
return userService.createUser(request);
}
@GetMapping("/users")
public GetUserListResponse getUserList() {
return userService.getUserList();
}
@GetMapping("/users/{userId}")
public UserDto getUser(@PathVariable("userId") Long userId) {
return userService.getUser(userId);
}
}
Dto Class들
xxxxxxxxxx
public class CreateUserRequest {
private String userName;
}
public class CreateUserResponse {
private Long userId;
}
access = AccessLevel.PROTECTED) (
public class GetUserListResponse {
private List<UserDto> userList;
}
public class UserDto {
private String userName;
}
요청및 응답에 사용될 dto 클래스들 입니다.
하나의 데이터를 반환할때에도 클래스로 반환
xxxxxxxxxx
위 dto들을 보면, 단 하나의 필드를 가지고 있는것들이 대부분입니다. 그런데 굳이 이런 데이터들을 클래스로 만들어 반환해야할까요?
네, 단하나의 필드를 가진 데이터를 반환하더라도 클래스로 만들어 반환하는 것이 좋습니다. 이유는 유연성 때문입니다.
PrimitiveType이나 컬렉션을 그대로 반환할때에 추후 반환할 데이터가 증가하는 경우, 이를 응답 데이터에 추가하기가 어렵습니다. 하지만, 클래스를 만들어 반환했을 때에는, 클래스 내부에 데이터를 추가해주면 됩니다.
2. 통합 테스트
단위 테스트로는 RequestMapping, Data Binding, Type Conversion, Validation
, 등등을 커버할 수 없습니다. 따라서 코드 커버리지를 높이기 위해서는 통합테스트를 실시해야합니다.
장점
- 모든 빈을 컨테이너에 올리고 테스트 하기 때문에 운영환경과 유사한 환경에서 테스트를 할 수 있습니다.
- 통합테스트 이름 그대로, 전체적인 테스트를 진행할 수 있어, 코드 커버리지가 높아집니다.
단점
- 모든 빈을 컨테이너에 올리고 테스트 하기 때문에 시간이 오래걸립니다.
- 전체적인 테스트를 한번에 진행하기 때문에, 특정 계층 또는 특정 빈에서 발생하는 오류의 디버깅이 어렵습니다.
2.1 통합테스트 Base Class
xxxxxxxxxx
public class BaseIntegrationTest {
protected MockMvc mvc;
protected ObjectMapper objectMapper;
}
모든 테스트 클래스의 부모가 될 기본 클래스입니다. MockMvc와 Object <-> String 변환을 위한 ObjectMapper를 필드 주입을 받아 가지고 있습니다.
@SpringBootTest
Spring boot가 아닌 Spring에서 test를 할때 사용하던 @ContextConfigruration
어노테이션의 대용 어노테이션으로, Test를 위한 Application Context를 로딩하며 여러가지 속성을 제공합니다.
- webEnvironment 속성
MOCK 으로 지정하는 경우, ApplicationContext를 로딩하며, embedded Server를 실행하지 않고, 가짜 웹 환경을 제공합니다.
실제 embedded server 실행을 원하는 경우, RANDOM_PORT 또는 DEFINED_PORT로 지정해야 합니다.
@Disabled
이 어노테이션을 지정하는 경우 해당 테스트 클래스 또는 테스트 메소드를 실행하지 않습니다. 위의 클래스의 경우 단지 설정을 상속하기 위한 클래스이기 때문에, 실행할 필요가 없습니다.
@Transactional
클래스 내부의 각각의 테스트 메소드가 실행될때마다, 데이터베이스를 롤백합니다. 데이터베이스에 의존성을 가지는 테스트코드를 대상으로 테스트 코드의 원칙중 하나인 반복가능한 테스트를 실현하기 위해 필요합니다.
@Transactional 롤백하지 않기
만약, 테스트 후 데이터를 직접 눈으로 보기 위해 데이터를 롤백하지 않고 싶다면, 메소드위에 @Rollback(false)을 부여하면 됩니다.
@AutoConfigureMockMvc
@WebMvcTest
어노테이션을 사용하지 않는 경우 즉, @SpringBootTest
어노테이션을 사용하는 경우, MockMvc를 이용한 테스트를 진행하기 위해 필요한 어노테이션입니다.
@WebMvcTest
어노테이션을 사용하는 경우, MockMvc는 자동으로 설정되며, 동시에 @AutoConfigureMockMvc
를 사용하게 되면, 충돌이 나게됩니다.
*@WebMvcTest
@WebMvcTest
어노테이션은 Web 계층(Controller)만을 테스트할때 사용하는 어노테이션입니다. 스프링 컨테이너에 @Controller, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor, WebMvcConfigurer 등과 같은 특정 빈들만을 등록하게 됩니다. 다른 계층의 빈들을 테스트 컨테이너에 올리지 않기 때문에, 보통 @MockBean
과 함께 사용하게 됩니다.
프로젝트에서 spring security를 사용하는 경우, spring security 설정 또한, 자동으로 진행합니다.
2.2 MockMvc 설명
MockMvc는 스프링 mvc의 통합테스트를 위한 라이브러리입니다.
MockMvc.perform()
mvc.perform()
메소드는 MockMvcRequestBuilders
를 매개변수로 받아, ResultActions
를 return하는 메소드 입니다. MockMvcRequestBuilders를 반환하는 정적 메소드로는 post(), get(), put(), delete() 등이 존재합니다.
이 메소드들은 HttpRequest를 만들어내기 위한 Builder로써, header, body 등을 지정하는 메소드들이 존재하며 이들은 다시 MockMvcRequestBuilders를 반환하기 때문에, 간편하게 테스트를 위한 웹 요청을 만들수 있습니다.
ResultActions.andDo()
mockmvc 요청을 한뒤, 행동을 지정하는 메소드입니다. 결과를 출력한다던지(print()) 로그를 출력하는 등의 행동을 지정할 수 있습니다.
ResultActions.andExpect()
요청의 결과로 예상(원하는) 응답을 지정함으로 실질적으로 테스트를 진행합니다.
응답코드, 본문에 포함되는 데이터, 헤더, 쿠키, 세션 등 응답에 포함되는 전반적인 데이터들을 테스트할 수 있습니다.
2.3 테스트 코드 팁
given, when, then
- 테스트 코드 작성시, 많은 곳에서 추천하는 코딩 스타일인데요, 어떤값이 주어지고(given), 무엇을 했을때(when), 어떤 값을 원한다(then)을 나누어 직관적으로 볼 수 있기 때문에, 테스트 코드의 가독성이 향상됩니다.
- 테스트 코드의 가독성이 중요한 또 다른 이유는, 테스트 코드가 문서로써의 역할을 하기도 하기 때문입니다. 테스트코드를 봄으로써, 해당 메소드를 작성한 개발자가 어떤의도로 만들었으며, 어떻게 동작하길 원하는지를 알 수있습니다.
모든 response에 대한 테스트를 진행한다.
- api가(테스트대상) 조금이라도 수정될 경우, 테스트코드가 실패하게 됨으로써, 항상 올바른 테스트 코드를 유지할 수 있도록 돕습니다.
- api가(테스트대상) 변경되면, 테스트 코드역시 변경되어야 하는 것은 당연합니다.
- 테스트 코드는 커버리지가 높을 수록 좋습니다. 테스트 코드는, 정상적으로 작동하는 부분만을 테스트 하면 안됩니다, 테스트 코드는 실수나 오류를 발견하고 이를 줄이고 수정하기 위해 작성하는 것입니다.
회원 조회등을 위한 생성 메서드를 만든다.
통합테스트를 진행하면, 데이터베이스의 특정 데이터에 의존하는 테스트가 존재하기 마련입니다. 이때, 조회 메소드의 테스트에 집중하기위해, 특정 데이터를 생성하는 테스트 메소드를 별도로 만들어 호출하는 것으로, 테스트를 용이하게 진행합니다.
참고 - https://www.popit.kr/spring-guide-%ED%85%8C%EC%8A%A4%ED%8C%85-%EC%A0%84%EB%9E%B5/
2.4 테스트 코드
유저 생성 테스트
x
class UserControllerTest extends BaseIntegrationTest {
private String USER_NAME = "TEST";
public void 유저생성() throws Exception {
//given
String USER_NAME = "TEST";
CreateUserRequest createRequest = new CreateUserRequest(USER_NAME);
//when
ResultActions resultActions = mvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createRequest))
.accept(MediaType.APPLICATION_JSON))
.andDo(print());
//then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("userId", is(notNullValue())));
}
}
given
절에서, 유저 생성을 위한 요청 dto를 생성합니다.
when
절에서, 테스트할 api를 지정합니다(post("/users")). contentType()을 이용해, post 요청의 body에 담길 데이터의 타입을 지정하며, given절에서 생성한 요청을 objectMapper를 이용해 String으로 변환하여, content() 의 매개변수로 넘겨주어 요청 본문에 담길 데이터를 지정합니다.
then
절에서 기대하는 응답 코드를 지정합니다. status().isOk() 의 경우 응답으로 200코드를 기대합니다. 마지막으로, 응답 본문에 담긴 userId의 값이 null이 아님을 기대하는 코드를 작성합니다.
유저 조회 테스트
x
class UserControllerTest extends BaseIntegrationTest {
private UserSetUp userSetUp;
...// 유저생성 테스트 코드
public void 유저조회() throws Exception {
//given
String USER_NAME = "TEST";
long USER_ID = userSetUp.saveUser(USER_NAME);
//when
ResultActions resultActions = mvc.perform(get("/users/{userId}", USER_ID)
.accept(MediaType.APPLICATION_JSON))
.andDo(print());
//then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("userName").value(USER_NAME));
}
public void 유저전체조회() throws Exception {
//given
String USER_1_NAME = "TEST1";
String USER_2_NAME = "TEST2";
userSetUp.saveUser(USER_1_NAME);
userSetUp.saveUser(USER_2_NAME);
//when
ResultActions resultActions = mvc.perform(get("/users")
.accept(MediaType.APPLICATION_JSON))
.andDo(print());
//then
resultActions
.andExpect(status().isOk())
.andExpect(jsonPath("$.userList", hasSize(2)))
.andExpect(jsonPath("$.userList[0].userName").value(USER_1_NAME))
.andExpect(jsonPath("$.userList[1].userName").value(USER_2_NAME));
}
}
public class UserSetUp {
private UserRepository userRepository;
public Long saveUser(String userName) {
UserEntity user = UserEntity.builder()
.userName(userName)
.build();
return userRepository.save(user).getUserId();
}
}
상단의 userSetUp은 데이터베이스에 의존하는 유저조회 테스트를 위한 보조 클래스입니다. saveUser() 메소드를 가지고 있으며 이는, 유저를 생성한 뒤 해당 유저의 id를 반환합니다.
'FrameWork > Spring Boot' 카테고리의 다른 글
Spring Boot - FCM Push 서버 구축하기 (15) | 2020.06.12 |
---|---|
Spring Boot - rest docs 사용방법과 자동 목차생성(spring boot restdocs 설정) (2) | 2020.06.03 |
Spring Boot -Spring boot 2.2.5 File upload 문제 (stream ended unexpectedly, Required request part is not present) (0) | 2020.05.12 |
Spring Boot - Custom Validator를 생성해야하는 경우와 생성방법(Collection 검증) (0) | 2020.04.10 |
Spring Boot - 한글, 한자 인코딩(Encoding) 에러시 체크리스트! (0) | 2020.01.17 |