Home Repository, Controller, Service 계층 테스트 (테스트와 인증)
Post
Cancel

Repository, Controller, Service 계층 테스트 (테스트와 인증)

테스트 코드를 작성하면서 각 계층별 테스트 코드가 각각 차이가 있고, 템플릿 형식으로 권장되는 방법이 있다는 사실을 알았다.

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'
This post is licensed under CC BY 4.0 by the author.