목차
Previous
스프링 부트에서는 내가 만든 애프리케이션을 테스팅 할 수 있도록 다양한 테스트 유틸리티와 애노테이션을 제공한다. 이렇게 제공되는 기능을 통해 우리는 손쉽게 단위 테스트부터 통합 테스트까지 수행을 할 수 있다.
이번 포스팅에서는 스프링 부트를 기반으로 테스트 도구를 어떻게 사용하는지부터 어떤 애노테이션이 있고 각각의 애노테이션의 역할과 기능에 대해 간략하게 알아볼 것이다. 하나하나에 대한 더 깊은 내용은 추후 포스팅 혹은 공식 문서를 통해 학습하도록 하자
Spring-boot-starter-test
개발자가 SpringBoot 프로젝트 첫 시작시 기본으로 설정하는 의존성에 테스트 라이브러리 의존성을 추가해준게 아니라면 다음 의존성을 자신이 사용하는 툴(maven or gradle)에 넣어주도록 하자.
•
Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>{version}</version>
<scope>test</scope>
</dependency>
XML
복사
•
Gradle
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Groovy
복사
해당 라이브러리에는 다음과 같은 라이브러리들이 같이 제공된다
1.
JUnit 5: Java 애플리케이션의 단위 테스트를 위한 표준 라이브러리
2.
Spring Test & Spring Boot Test: 스프링 부트 애플리케이션에 대한 유틸리티 및 통합테스트 지원
3.
AssertJ: 하나의 가정이 올바른지 검사할 수 있도록 도와주는 fluent API 라이브러리
4.
Hamcrest: Matcher Object 라이브러리로 필터, 검색등을 위해 값을 비교할 때 좀 더 편리하게 사용할 수 있게 해준다.
5.
Mockito : 자바 모킹 프레임워크 라이브러리로 테스트용 임시객체를 만들어 테스트 고립성을 지켜줄 수 있다.
6.
JSONassert: JSON의 검증을 위한 라이브러리
7.
JsonPath: JSON을 XPath방식으로 검사할 수 있게 도와주는 라이브러리
위와같이 포함된 여러 라이브러리를 이용하여 우리는 스프링 부트 프로젝트 내에서 단위테스트, 인수테스트, 통합테스트를 편하게 구현하여 검증할 수 있다.
@SpringBootTest
목적
통합 테스트를 제공하는 가장 기본적인 테스트 애노테이션으로 애플리케이션이 실행 될 때의 설정을 임의로 바꿀수도 있고 여러 단위 테스트를 하나의 통합 테스트로 수행 할 수도 있다.
범위
해당 애노테이션의 컴포넌트 스캔 범위는 Bean 전체이다. 즉 애플리케이션이 실행할 당시 스캔되는 범위와 동일하다. 그렇기에 최대한 실제와 유사한 환경에서 테스트를 할 수 있다는 장점이 있다.
하지만, 이 말은 반대로 애플리케이션의 모든 설정을 가져오기 때문에 애플리케이션의 범위가 넓을수록 테스트가 느려질 수 밖에 없고, 이는 단위테스트의 의미를 희석하기에 단위테스트에 적절하지는 않다.
속성
•
properties : 속성 값을 정의한다. value나 생략하고 입력해도 동일하게 동작한다.
@SpringBootTest({"name=catsbi", "email=catsbi@email.com"})
@SpringBootTest(value = {"name=catsbi", "email=catsbi@email.com"})
@SpringBootTest(properties = {"name=catsbi", "email=catsbi@email.com"})
Java
복사
•
classes: SpringBootTest 는 모든 빈을 등록한다고 했는데 classes 속성을 정의하면 해당 클래스의 빈만 정의된다. @configuration 으로 지정한 설정도 등록할 수 있다.
@SpringBootTest(classes = {SessionController.class, WebConfig.class})
Java
복사
•
webEnvironment
◦
WebEnvironment.MOCK : 기본적으로 설정되는 기본 설정으로 내장 톰캣이 구동되지 않는다.
◦
WebEnvironment.RANDOM_PORT: 포트가 랜덤으로 지정되어 상용 앱에서 구동되는 것처럼 내장 톰캣이 구동된다.
◦
WebEnvironment.DEFINED_PORT: 정의된 포트로 내장톰캣이 구동된다.
◦
WebEnvironment.NONE: WebEnvironment.NONE으로 구동된다
•
args: 애플리케이션의 arguments를 삽입할 수 있다.
@SpringBootTest(args = {"--catsbi.email=catsbi@email.com"})
Java
복사
사용 예제
•
사전 조건
◦
SessionController를 테스트 할 것이기에 SessionController, WebConfig만 등록한다.
◦
시작할 때 포트는 8443 포트로 지정한다.
◦
jwt 토큰의 키는 12345678901234567890123456789012 이다
◦
테스트로 사용 될 이메일과 비밀번호는 각각 catsbi@email.com , q1w2e3 이다.
server:
port: 8443
YAML
복사
application.properties
@SpringBootTest(properties = {"email=catsbi@email.com", "password=q1w2e3"},
args = {"--secret=12345678901234567890123456789012"},
classes = {SessionController.class, WebConfig.class},
webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT
)
class SessionControllerTest{
@Value("#environment[email]")
private String email;
@Value("#environment[password]")
private String password;
@Autowired
private ApplicationArguments args;
private Key key;
@BeforeEach
void setUp(){
String secret = args.getOptionValues("secret")
key = Keys.hmacShaKeyFor(secret.getBytes());
//...
}
}
Java
복사
@WebMvcTest
역할과 목적
애노테이션 이름 그대로 Web 테스트 그 중에서 MVC 테스트를 하는데 사용하며 컴포넌트 스캔의 범위는 스프링 컨테이너에서 Presentation Layer에 속하는 빈들만 등록한다.
웹상에서 요청과 응답에 대한 테스트를 진행한다. 그렇기에 보통 MockMvc와 함께 사용한다. 컴포넌트 스캔 대상이 다음과 같이 제한적이기에 기존의 SpringBootTest에 비해 속도가 빠르다.
•
@Controller, @ControllerAdvice, @JsonComponent, @Filter, WebMvcConfigurer, HandlerMethodArgumentResolver, MockMvc
주의점
보통 컨트롤러에서는 서비스계층의 메소드들을 호출하게 되는데 이는 빈 등록 대상이 아니기에 @Autowired 와 같은 애노테이션을 사용할 수 없다. 그렇기에 @MockBean 이나 혹은 mock(), spy()등을 이용해 해당 메서드를 mocking해줘야 한다.
속성
@SprinbBootTest와 동일한 속성은 제외하고 설명한다.
•
useDefaultFilters: @SpringBootTest와 동일한 필터를 등록할지 설정한다. 기본은 true이며 기본적으로 @Controller와 @ControllerAdvice, WebMvcConfigurer 빈들이 포함된다.
•
includeFilters: 기본적으로 등록되는 필터 외에 추가적으로 필터를 추가한다.
•
excludeFilters: 추가되는 빈들 중 임의로 제외하고싶은 필터를 등록한다.
•
excludeAutoConfiguration: 해당 테스트에 적용되는 자동 설정들에서 제외할 빈을 등록한다.
TIP. @TestConfiguration
하지만, 테스트를 하다보면 WebMvcTest의 스캔 범위를 벗어난 빈들도 실제로 등록해야 할 순간도 있고, FakeObject를 임의로 주입해야 하는 상황도 올 수 있다.
그럴 경우 @TestConfiguration 을 이용해 기존에 정의한 Configuration을 커스터마이징 할 수 있는데 이 설정은 ComponentScan 과정에서 생성되며 테스트가 실행될 때 정의된 빈을 생성하여 등록할 것이다.
@TestConfiguration
public class TestWebConfig {
@Bean
public UserRepository userRepository() {
return InMemoryUserRepository.getInstance();
}
}
Java
복사
스캔 대상이 아닌 UserRepository 빈을 등록해주며 더하여 이를 FakeObject인 InMemoryUserRepository를 등록해줘서 실제 데이터베이스가 아닌 in memory 상에서 테스트를 수행할 수 있다.
사용 예제
•
사전 조건
◦
상품 정보 Restful API를 테스트 한다
◦
MockMvc를 이용하여 테스트한다.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@MockBean
private ProductService productService;
private Product product;
@BeforeEach
void setUp(){
product = Product.of(1L, "장난감뱀", 5000L, "고양이 주식회사");
given(productService.save(any(Product.class))
.willReturn(product);
given(productService.findProduct(eq(100L))
.willThrow(new ProductNotFoundException(100L));
}
@Test
void createProductTest(){
mockMvc.perform(
post("/products")
.accept(MediaType.APPLICATION_JSON_UTF8)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"장난감뱀\",\"maker\":\"고양이 주식회사\"," +
"\"price\":5000}")
)
.andExpect(status().isCreated())
.andExpect(content().string(containsString("장난감뱀")));
}
}
Java
복사
◦
given(productService.save(any(Product.class)).willReturn(product);
: Test Double의 Stubbing 방식 중 하나로 테스트 케이스에 의해서 컨트롤러에서 productService의 특정 메서드를 호출할 때 지정된 답변으로 응답하도록 한다. 예를들어 해당 코드는 productService에서 save메서드를 호출하며 파라미터로 내용이 어떻게 되던 Product.class 타입의 객체를 전달하면 미리 생성해 둔 product 인스턴스를 반환한다.
이러한 given, when, willReturn, thenReturn 메서드들은 BDDMockito에 정의되어 있으며 위에 작성된 방식 말고도 will 을 통해 내가 로직을 직접 작성할수도 있고 메서드 체이닝 방식을 통해 같은 메서드를 여러번 호출하면 호출 할 때마다 다른 값을 반환 혹은 예외를 반환하게 할 수도 있다.
@DataJpaTest
역할과 목적
Spring Data JPA를 사용할 경우 Repository 관련 빈을 Context에 등록해 테스트 시 활용할 수 있게 도와주는 애노테이션
해당 애노테이션을 사용해 테스트를 진행하면 in-memory embedded database(내장메모리) 를 생성하여 @Entity 애노테이션이 붙은 클래스들을 스캔한다.
또한, 해당 애노테이션은 @Transactional 애노테이션을 포함하기에 테스트가 종료되면 자동으로 롤백된다.
@Transactional
: @DataJpaTest 에서 기본적으로 포함하는 애노테이션으로 따로 선언을 하지 않아도 되며, 만약 테스트 시 해당 기능을 사용하지 않고 싶을 경우 속성 중 propagation을 NOT_SUPPORTED로 설정해주면 된다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
Java
복사
@AutoConfigureTestDatabase
위에서 말했듯 @DataJpaTest 에서는 in-memory 기반의 데이터베이스를 사용한다. 하지만, 인메모리 기반이 아닌 실제 데이터베이스를 대상으로 테스트를 진행하고 싶다면 해당 애노테이션을 사용하면 된다. 기본적으로는 Any로 설정되어 있어 인메모리 데이터베이스를 사용하지만 다음과 같이 속성을 Replace.NONE으로 지정하면 실제 데이터베이스를 사용할 수 있다.
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
Java
복사
참고로 이 경우 @ActiveProfiles("xxx") 애노테이션을 통해 프로파일 설정을 해 줄 수도 있다.
개발 환경에 프로덕션 데이터베이스에 더해 개발용 데이터베이스가 따로 있을 경우 유용하다.
사용 예제
@DisplayName("AccountService 클래스")
@DataJpaTest
public class AccountServiceNestedTest {
AccountService accountService;
@Autowired
AccountRepository accountRepository;
@BeforeEach
void setUp() {
accountService = new AccountService(accountRepository);
}
@Nested
@DisplayName("create 메서드는")
class Describe_create {
@Nested
@DisplayName("인자값이 정상인 경우")
class Context_without_data {
@DisplayName("정상적으로 생성되어 반환된다.")
@Test
void createAccount() {
AccountSaveData source = AccountSaveData.of(ACCOUNT_NAME, ACCOUNT_EMAIL, ACCOUNT_PASSWORD);
final AccountSaveData savedData = accountService.creation(source);
assertThat(savedData.getId()).isNotNull();
assertThat(savedData.getName()).isEqualTo(ACCOUNT_NAME);
assertThat(savedData.getEmail()).isEqualTo(ACCOUNT_EMAIL);
assertThat(savedData.getPassword()).isEqualTo(ACCOUNT_PASSWORD);
}
}
}
}
Java
복사
임의로 TestConfiguration 등록하기
@SpringBootTest를 사용하면 모든 빈이 등록되지만 속도가 느리기에 @WebMvcTest, @DataJpaTest를 고려하지만, 해당 애노테이션은 내가 등록하길 원하는 애노테이션까지 슬라이싱 할 수 있고, 또한 테스트에서는 FakeObject를 주입해주고 싶은 경우 사용할 수 있는 방법이 있다.
@Import와 @TestConfiguration
여기서 TestConfiguration은 위에서 이미 설명한 바 있는데, 이를 @Import 애노테이션을 이용해 임의로 테스트에 주입하여 사용할 수 있다.
사용예제
•
사전 조건
◦
UserRepository 에 등록될 빈은 FakeObject인 InMemoryUserRepository여야한다.
◦
WebMvcTest를 사용하지만 given/willReturn같은 mocking을 하지 않는다.
@TestConfiguration
public class TestWebConfig {
@Value("${jwt.secret}")
private String secret;
@Bean
public JwtUtil jwtUtil() {
return new JwtUtil(secret);
}
@Bean
public UserRepository userRepository() {
return InMemoryUserRepository.getInstance();
}
@Bean
public AuthenticationService authenticationService() {
return new AuthenticationService(userRepository(), jwtUtil());
}
}
Java
복사
테스트용 설정 클래스로 Configuration 을 커스터마이징 하며, userRepository빈으로 InMemoryUserRepository를 반환한다.
@DisplayName("SessionController 클래스")
@WebMvcTest(SessionController.class)
@Import(TestWebConfig.class)
class SessionControllerNestedTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
private User user;
private ObjectMapper objectMapper;
@Autowired
private JwtUtil jwtUtil;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
userRepository.deleteAll();
user = User.builder()
.name(NAME)
.email(EMAIL)
.password(PASSWORD)
.build();
user = userRepository.save(user);
mockSetUp();
}
private void mockSetUp() {
}
@Nested
@DisplayName("login 메서드는")
class Describe_login {
@Nested
@DisplayName("이메일이 존재하고, 비밀번호가 일치한다면")
class Context_with_exists_email_and_matched_password {
@DisplayName("토큰이 발급되며 201 응답코드를 반환한다.")
@Test
void loginWithValidForm() throws Exception {
mockMvc.perform(
post("/session")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(toJson(LoginForm.of(EMAIL, PASSWORD)))
)
.andExpect(status().isCreated())
.andExpect(content().string(containsString(".")))
.andDo((result) -> {
final String responseData = result.getResponse().getContentAsString();
final SessionResponseData sessionResponseData = objectMapper.readValue(responseData,
SessionResponseData.class);
final Long decodeId = jwtUtil.decode(sessionResponseData.getAccessToken());
assertThat(decodeId).isEqualTo(user.getId());
});
}
}
@Nested
@DisplayName("이메일이 존재하고, 비밀번호가 일치하지 않는다면")
class Context_with_exists_email_and_unmatched_password {
@DisplayName("예외가 발생하며 401 응답코드를 반환한다.")
@Test
void loginWithNotMatchedForm() throws Exception {
mockMvc.perform(
post("/session")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(toJson(LoginForm.of(EMAIL, "other")))
)
.andExpect(status().isBadRequest());
}
}
@Nested
@DisplayName("이메일이 존재하지 않는 경우")
class Context_with_not_exists_email {
@DisplayName("예외가 발생하며 401 응답코드를 반환한다.")
@Test
void loginWithNotExistsEmail() throws Exception {
mockMvc.perform(
post("/session")
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(toJson(LoginForm.of("other", PASSWORD)))
)
.andExpect(status().isNotFound());
}
}
}
private byte[] toJson(LoginForm form) throws JsonProcessingException {
return objectMapper.writeValueAsBytes(form);
}
}
Java
복사
•
@Import(TestWebConfig.class)
: 위에서 생성한 테스트용 설정 클래스를 @Import 애노테이션으로 주입해주면 해당 테스트에서 해당 설정을 통해 빈을 주입받아 사용할 수 있다.
•
그래서 해당 테스트에서 service, repository까지 mocking을 하지 않고 테스트를 진행할 수 있다.
mocking을 과도하게 사용하는 것을 주의하자.
given/willReturn을 사용하면 내가 테스트하고자 하는 영역 외의 부분까지 신경쓸 필요가 없고 약속된 결과를 반환하게 stubbing 해주면서 나는 해당 테스트에서 테스트 대상에 집중할 수 있다는 장점이 있다. 그렇기에 얼핏 생각하면 given/willReturn을 사용하는 것이 위처럼 복잡하게 서비스와 리포지토리를 주입하는것보다 좋아 보인다.
하지만, 프로젝트의 규모가 커지고 기획이 변경되며 메서드의 반환값 혹은 전달 값이 수정된다면 기존의 결과를 토대로 mocking 했던 모든 테스트들은 위험해진다. 실제로는 바뀐 상태지만 테스트에서는 바뀌기전이라는 괴리속에서 테스트가 통과하면서 실제에서는 문게가 터질 수 있는데, mocking이 많아질수록 위험도는 높아질 수 있다.
그렇기에 mocking은 굉장이 유용하고 테스트 대상에 집중할 수 있게 해주지만, 꼭 필요한 상황이 아니고 비교적 복잡하지 않은 mocking을 사용하지 않아도 되는 경우라면 자제하도록 하자.