테스트 코드를 작성하면서 각 계층별 테스트 코드가 각각 차이가 있고, 템플릿 형식으로 권장되는 방법이 있다는 사실을 알았다.
Entity의 테스트 방법은 단순하게 자바 코드로 객체의 생성, 객체 내 메서드 등을 테스트해주면 되기 때문에 따로 정리하지 않고 Repository, Controller, Service 계층의 코드를 어떻게 테스트하면 좋을 지 정리해본다.
테스트에 사용하는 애노테이션
@SpringBootTest
- 통합 테스트를 위한 환경을 만들어준다.
- 모든 빈들을 스캔하여 Application Context를 생성한다.
- @MockBean으로 정의된 Bean을 찾아 대체시킨다.
@SpringBootTest의 단점
모든 빈을 탐색하고 등록하기 때문에 특정 계층만 테스트가 필요한 경우 @SpringBootTest를 사용하게 되면 시간이 오래 걸린다.
스프링은 이를 보완하기 위해 특정 부분만 테스트할 수 있는 슬라이스 테스트를 위한 애노테이션들을 제공한다.
@WebMvcTest
- Application Context를 만들 때 컨트롤러와 관련된 빈들만을 제한적으로 찾아 등록한다.
- 따라서 @Component나 @ConfigurationProperties 빈들은 스캔되지 않는다.
- @Controller, @RestController
- @ControllerAdvice, @RestControllerAdvice
- @JsonComponent
- FIlter
- WebMvcConfigurer
- HandlerMethodArgumentResolver
- 등등
@DataJpaTest
@DataJpaTest는 기본적으로 @Entity가 있는 엔티티 클래스들을 스캔하여 테스트를 위한 TestEntityManager를 사용해 JPA 레포지토리들을 설정해준다.
기본 동작
스프링은 테스트에 @Transactional이 있으면 테스트가 끝난 후 자동으로 트랜잭션을 롤백한다.
@DataJpaTest 애노테이션 내부에는 @Transactional 애노테이션이 포함되어 있다. 따라서 모든 테스트가 롤백된다.
만약 롤백을 원하지 않는다면 @Rollback(false)를 추가하면 된다.
또한 만약 H2와 같은 내장 데이터베이스가 클래스 패스에 존재한다면 내장 데이터베이스가 자동 구성된다. spring-boot-test 의존성에는 기본적으로 H2가 들어있으므로 별다른 설정을 주지 않는다면 H2로 설정된다. 내장 데이터베이스로 설정되기를 원하지 않는다면 AutoConfigureTestDatabase의 replace 속성을 NONE으로 주면 된다.
@ExtendWith
- JUnit의 테스트 실행 방법을 정의하는 애노테이션이다.
- 실제 테스트 클래스를 호출하는 책임이 있는 클래스이다.
- @Autowired, @MockBean 등을 로딩하는 주체이다.
- @SpringBootTest는 Application Context를 전부 구성하는 반면 @ExtendWith는 테스트 파일에 정의된 @Autowired, @MockBean 등만 Application Context로 로딩하는 역할을 수행한다.
@Mock, @MockBean
- @MockBean
- 해당 애노테이션이 붙은 클래스에 관해 MockContext에 등록한다.
- @Mock과는 관련이 없다.
- 이를 사용하여 등록하는 객체에 대해서는 @InjectMocks가 작동하지 않는다.
- @SpringBootTest를 통해 Autowired에 의존성이 주입된다.
- @Mock
- 가짜 객체를 생성해준다.
- @InjectMocks에 등록해준다.
- Mock 객체이므로 행동을 정의해주어야 한다.
@Spy, @SpyBean
- @SpyBean, @Spy
- 가짜 객체를 만들어 stub을 해주는 @MockBean과 달리 @SpyBean은 given에서 stub한 메서드 이외에는 그 객체의 실제 메서드를 사용한다.
- 특정 객체의 일부 메서드만 stub하고, 나머지는 실제 메서드를 사용하고 싶을 때 사용한다.
@InjectMocks
- 해당 애노테이션이 붙은 클래스가 필요한 의존성과 맞는 Mock 객체들을 감지하여 해당 클래스의 객체가 만들어질 때 필요한 객체를 주입하게 된다.
ex) UserController에 대한 단위테스트를 작성하고자 할 때, UserService를 사용하고 있다면 @Mock으로 가짜 애노테이션을 만들고 @InjectMocks를 통해 UserController에 주입시킬 수 있다.
참고
@MockBean과 @SpyBean은 특정 빈을 Mock이 적용된 빈으로 등록한다.
그러므로 애플리케이션 컨텍스트가 갖는 빈이 달라져 새로운 컨텍스트를 생성하게 된다.
@MockBean과 @SpyBean을 많이 사용했을 경우 테스트가 느려질 수 있으며 캐싱된 애플리케이션 컨텍스트의 수를 증가시킨다.
따라서 만약 테스트 속도가 느리다면 테스트들마다 애플리케이션 컨텍스트가 생성되는지 확인하고 개선해보자.
Repository 테스트
간단한 회원 리포지토리의 테스트 코드를 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@DataJpaTest
class AccountRepositoryTest {
@Autowired
AccountRepository accountRepository;
@BeforeEach
void setup() {
Account savedUsername = Account.builder()
.username("abcd1234")
.build();
Account savedNickName = Account.builder()
.nickName("닉네임")
.build();
accountRepository.save(savedUsername);
accountRepository.save(savedNickName);
}
@Test
@DisplayName("아이디로 계정 조회")
void findByUsername() {
//given
Account findAccount = accountRepository.findByUsername("abcd1234")
.orElse(null);
Account noAccount = accountRepository.findByUsername("1234")
.orElse(null);
String findUsername = findAccount.getUsername();
//then
assertEquals(findUsername, "abcd1234");
assertNull(noAccount);
}
@Test
@DisplayName("닉네임으로 계정 조회")
void findByNickName() {
//given
Account findAccount = accountRepository.findByNickName("닉네임")
.orElse(null);
Account noAccount = accountRepository.findByNickName("abcd1234")
.orElse(null);
String findAccountNickName = findAccount.getNickName();
//then
assertEquals(findAccountNickName, "닉네임");
assertNull(noAccount);
}
}
- @DataJpaTest 애노테이션을 통해 JPA를 테스트했다.
- 메모리 DB를 자동으로 사용하여 테스트하도록 설정했다.
- 만약 메모리 DB가 아닌 특정 DB를 사용하고 싶다면 @AutoConfigureTestDatabase 애노테이션을 사용하면 된다.
- @Autowired를 통해 Repository를 주입받았다.
- @BeforeEach를 이용해 데이터를 세팅해주었다.
위와 같은 작업을 통해 테스트를 세팅하고, 해당 Repository 내의 메서드를 성공과 실패로 나누어 테스트할 수 있다.
Service 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock
AccountRepository accountRepository;
@Mock
MbtiRepository mbtiRepository;
@Mock
BCryptPasswordEncoder bCryptPasswordEncoder;
@InjectMocks
AccountService accountService;
@Test
@DisplayName("아이디가 중복되지 않았을 경우 테스트")
void testUsernameNonDuplicate() {
given(accountRepository.existsByUsername("username")).willReturn(true);
boolean result = accountService.isUsernameDuplicate("username");
assertFalse(result);
}
@Test
@DisplayName("아이디가 중복됐을 경우 테스트")
void testUsernameDuplicate() {
given(accountRepository.existsByUsername("username")).willReturn(false);
boolean result = accountService.isUsernameDuplicate("username");
assertTrue(result);
}
@Test
@DisplayName("닉네임이 중복되지 않았을 경우 테스트")
void testNickNameNonDuplicate() {
given(accountRepository.existsByNickName("닉네임")).willReturn(true);
boolean result = accountService.isNickNameDuplicate("닉네임");
assertFalse(result);
}
@Test
@DisplayName("닉네임이 중복됐을 경우 테스트")
void testNickNameDuplicate() {
given(accountRepository.existsByNickName("닉네임")).willReturn(false);
boolean result = accountService.isNickNameDuplicate("닉네임");
assertTrue(result);
}
@Test
@DisplayName("회원등록이 됐을 경우 Account 객체가 반환되어야 한다.")
void testRegister() {
RegisterDto registerDto = RegisterDto.builder()
.birthYear(1997)
.username("username")
.gender(Gender.MALE)
.mbtiType("ISTJ")
.password("1234")
.build();
Mbti mockMbti = Mbti.create(MbtiType.ISTJ);
given(mbtiRepository.findByMbtiType(any())).willReturn(mockMbti);
given(bCryptPasswordEncoder.encode(any())).willReturn("encodedPw");
given(accountRepository.save(any())).willReturn(Account.builder().build());
Account result = accountService.register(registerDto);
assertNotNull(result);
}
}
Service 계층을 테스트하기 위해 Repository와 같은 필요한 부분을 모킹하는 방법을 사용했다.
위 기능들을 사용하기 위해 @ExtendWith 애노테이션을 사용해주었다.
- @InjectMocks를 이용해 테스트하고자 하는 서비스 클래스에 @Mock 애노테이션이 붙은 가짜 객체를 주입했다.
- given을 통해 사용되는 메서드가 return하는 값을 지정해주고 그 값을 이용해 검증한다.
Controller 테스트
컨트롤러 테스트 또한 서비스 계층처럼 @ExtendWith 애노테이션을 활용해 테스트할 수 있다.
그러나 이번엔 @WebMvcTest 애노테이션을 사용해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@WebMvcTest(BoardController.class)
@MockBean(JpaMetamodelMappingContext.class)
class BoardControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private WebtoonService webtoonService;
@Test
@DisplayName("게시판 웹툰 정보 조회 테스트")
@WithMockUser("user1")
void testGetWebtoonDetails() throws Exception {
WebtoonBoardDto webtoonDetails = WebtoonBoardDto.builder()
.title("title")
.link("https://")
.author("author")
.story("story")
.imgSrc("imgSrc")
.genres(new HashSet<>())
.build();
given(webtoonService.getWebtoonByTitleId(anyString())).willReturn(webtoonDetails);
mockMvc.perform(get("/api/board/{titleId}", "titleId"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.response").exists())
.andExpect(jsonPath("$.response.title").value("title"))
.andExpect(jsonPath("$.response.link").value("https://"))
.andExpect(jsonPath("$.response.author").value("author"))
.andExpect(jsonPath("$.response.story").value("story"))
.andExpect(jsonPath("$.response.imgSrc").value("imgSrc"))
.andExpect(jsonPath("$.response.genres").isArray());
}
}
- @WebMvcTest 애노테이션을 사용하여 @MockBean 애노테이션을 사용했다.
- 프로젝트에서 DB 저장 시 데이터 생성 시각 등의 정보를 위해 @EnableJpaAuditing 애노테이션을 사용했는데 @WebMvcTest를 통해 테스트를 진행하면 application 시작 지점부터 필요한 빈들을 주입할 때 이 때 JPA와 관련된 빈들을 등록하지 않아 에러가 발생한다.
- 이 문제를 해결하기 위해 @MockBean애노테이션을 사용하였다.
- JPA 관련 빈들을 Mock으로 등록하도록 처리했다.
- 임의의 응답 DTO를 생성하여 그 값을 제대로 가져오는지 테스트하였다.
- MockMvc를 활용하여 테스트했다.
참고로 get, jsonPath 등의 메서드를 활용하기 위해서는 아래가 필요하다.
1
2
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
컨트롤러 테스트의 Security 문제
프로젝트는 JWT 토큰을 이용한 인증방식이 구현되어 있고, 테스트 시 인증이 되지 않아 처음 테스트를 진행할 때 401 응답을 받아볼 수 있었다.
그런데 분명 해당 BoardController의 요청에는 모든 권한에 접근을 허용해둔 것으로 기억하는데 왜 401에러가 나타났을까?
- @WebMvcTest
- MockMvc를 제어하는 애노테이션이다.
- @AutoConfigurerMockMvc와 다르게 @Controller, @ControllerAdvice 등이 사용이 가능하지만, @Service, @Component, @Repository 등이 사용 불가능하다.
- 일반적으로 @MockBean 또는 @Import와 함께 사용되어 컨트롤러 빈에 필요한 협력자를 생성한다.
- 웹 상에서 요청과 응답에 대해 테스트할 수 있고 Security 혹은 Filter까지 자동으로 테스트하여 수동으로 추가/삭제가 가능하다.
- @AutoConfigureMockMvc
- MockMvc를 제어하는 애노테이션이다.
- @WebMvcTEst와 다르게 전체 구성을 메모리에 올린다.
- MockMvc를 보다 세밀하게 제어하기 위해 사용한다.
- 전체 어플리케이션 구성을 로드해서 실제 환경과 동일하게 테스트를 진행해야 한다면 @AutoConfigureMockMvc와 @SpringBootTest를 결합한 방법을 고려하면 된다.
이와 같은 차이로 테스트에 사용하는 애노테이션을 아래와 같이 바꾼다면 문제는 해결된다.
1
2
3
4
5
6
7
8
9
@AutoConfigureMockMvc
@SpringBootTest
class BoardControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
WebtoonService webtoonService;
하지만 @WebMvcTest를 사용하면 더 빠르고 쉬운 테스트가 가능하다. 즉 비효율적인 해결 방법이다.
그런데 이 과정속에서도 의문이 있다. 나는 결국 인증에 상관없이 해당 요청은 열어놨는데 왜 전체 구성을 가져오는 방식은 에러가 발생하지 않고 @WebMvcTest에서는 에러가 발생했을까?
- @WebMvcTest
- Spring Security Configuration이 불러와지지 않는다.
- Spring Security가 자동으로 구성하는 Configuration 파일들을 불러와 사용한다.
- 즉 내가 구현한 Security Configuration은 적용되지 않는 것이다.
자동으로 구성하는 Configuration 중 일부를 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {
@Bean
@Order(SecurityProperties.BASIC_AUTH_ORDER)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic();
return http.build();
}
}
모든 요청에 대해 권한을 가지고 있다면 허용한다.
그런데 위 테스트의 엔드포인트에는 어떤 아무런 권한 설정이 되어있지 않아 401 에러가 발생한 것이었다.
해결
요청을 할 때 권한을 넘겨주면 된다.
인증된 Mock 유저를 생성하는 방식을 활용해보자.
- @WithMockUser
- 인증된 사용자
- @WithAnonymousUser
- 미인증 사용자
- @WithUserDetails
- 메서드가 principal 내부의 값을 직접 사용하는 경우
@WithMockUser를 사용하여 해결했다.
참고로 해당 애노테이션들을 사용하기 위해서는 아래가 필요하다.
1
testImplementation 'org.springframework.security:spring-security-test'