처음으로 Controller, Service, Repository 별로 격리된 환경에서 단위 테스트 코드를 작성하다보니 모든 게 낯설고 생소했습니다. 처음에는 어노테이션 하나, Mock 객체 주입 하나도 제대로 이해하지 못하고 사용하니 Failed to load Application Context 등 셀수 없는 오류를 만났습니다.
아래는 각 단위 테스트 코드를 작성하며 공부하고 이해한 내용을 정리한 것입니다.
@WebMvcTest(controllers = {BoardController.class})
class BoardControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
@Autowired
BoardService boardServiceMock;
@Autowired
WebApplicationContext was;
@BeforeEach
public void setUp() {
// 1번 - webAppContextSetup
this.mockMvc = MockMvcBuilders
.webAppContextSetup(was)
.build();
// 2번 - standaloneSetup
this.mockMvc = MockMvcBuilders
.standaloneSetup(new BoardController(boardServiceMock))
.build();
}
@Test
@DisplayName("GET : 전체 게시글 조회")
void getPosts() throws Exception {
mockMvc.perform(get("/posts"))
.andExpect(status().isOk())
.andExpect(view().name("board/posts"));
}
@Test
@DisplayName("GET : 검색 게시글 조회")
@WithMockCustomAccount
void getSearchPosts() throws Exception {
mockMvc.perform(get("/search"))
.andExpect(status().isOk())
.andExpect(view().name("board/posts"));
}
}
BoardController Class의 단위테스트 코드를 먼저 보자.
스프링 공식문서를 보면 이 어노테이션에 대해 제대로 이해할 수가 있다.
Annotation that can be used for a Spring MVC test that focuses only on Spring MVC components.
Spring MVC 컴포넌트들에만 집중해서 MVC 테스트를 진행하고 싶을 때 사용하는 어노테이션이라고 한다.
Using this annotation will disable full auto-configuration and instead apply only configuration relevant to MVC tests (i.e. @Controller
, @ControllerAdvice
, @JsonComponent
, Converter
/GenericConverter
, Filter
, WebMvcConfigurer
and HandlerMethodArgumentResolver
beans but not @Component
, @Service
or @Repository
beans).
이 어노테이션을 사용하면 MVC 테스트에 필요한 설정, 빈 정보들만 가져오게 되고 통합테스트 처럼 전체 설정내용을 다 가져오지는 않는다. 예를 들어, Controller, WebMvcConfigurer 와 같은 컴포넌트들은 가져오지만 예를 들어 Controller 테스트에 필요없는 일반 Component, Service, Repository 빈들은 가져오지 않는다. (그래서 원래 Controller가 Service 의존성을 주입받고 있다면 MockBean을 붙여줘서 객체를 생성해줘야 하는 것이다.)
By default, tests annotated with @WebMvcTest
will also auto-configure Spring Security and MockMvc
(include support for HtmlUnit WebClient and Selenium WebDriver). For more fine-grained control of MockMVC the @AutoConfigureMockMvc
annotation can be used.
그리고 WebMvcTest 어노테이션이 붙은 테스트는 자동으로 Spring Security 와 MockMvc를 자동설정해준다고 한다. (그래서 http.antMatcher에 없는 즉 로그인 인증이 필요한 url의 경우 접근이 막혀 있는 것까지 테스트가 가능했다.)
Typically @WebMvcTest
is used in combination with @MockBean
or @Import
to create any collaborators required by your @Controller
beans.
그래서 위에 말했던 것처럼 Controller 관련 되지 않은 Bean들은 가져오지 않기 때문에 MockBean 이런 어노테이션과 함께 쓰인다고 한다.
If you are looking to load your full application configuration and use MockMVC, you should consider @SpringBootTest
combined with @AutoConfigureMockMvc
rather than this annotation.
만약에 애플리케이션 전체의 configuration 을 다 가져오고 싶다면 이 어노테이션 보다는 SpringBootTest 즉 통합 테스트 때 쓰이는 이 어노테이션 사용을 한번 고려해보라고 한다.
When using JUnit 4, this annotation should be used in combination with @RunWith(SpringRunner.class)
.
JUnit 4에서는 이 어노테이션은 @Runwith(SpringRunner.class)와 함께 쓰였다고 하는데 나는 JUnit 5를 사용했고, 해당없는 사항이었다.
어쨌든, 단위테스트의 목적으로 위와 같은 여러 사항들을 고려해서 WebMvcTest 어노테이션을 달아주었고 어떤 Controller를 테스트할 것인지도 옆에 적어주었다.
MockMvc는 컨트롤러 테스트를 할 때, 실제 서버에 구현한 애플리케이션을 올리지 않고(실제 서블릿 컨테이너를 사용하지 않고) 테스트용 MVC환경을 만들어 요청 및 전송, 응답기능을 제공해주는 유틸리티 클래스다.
이 기능으로 실제 서블릿 컨테이너에서 컨트롤러를 실행하지 않고도 컨트롤러에 HTTP 요청을 할 수 있다.
mockMvc를 설정하기 위해서는 MockMvcBuilder를 사용해야 하는데, standaloneSetup과 webAppContextSetup이라는 정적 메서드 중 하나를 선택해야 한다.
처음에는 standaloneSetup으로만 하면 충분할 거라고 생각했다.
// 2번 - standaloneSetup
this.mockMvc = MockMvcBuilders
.standaloneSetup(new BoardController(boardServiceMock))
.build();
그래서 2번 방식으로 setup을 해주었는데 문제가 생겼다. 게시글 전체 조회 기능의 경우 로그인 하지 않은 사용자도 접근이 가능하기 때문에 테스트에 통과했는데, 나머지 기능의 경우 Authentication 정보를 가져와야 했다. 그러다 보니 1번 방식으로 바꿀 수 밖에 없었다.
// 1번 - webAppContextSetup
this.mockMvc = MockMvcBuilders
.webAppContextSetup(was)
.build();
WithSecurityContextFactory
인터페이스를 상속받은 WithMockCustomAccountSecurityContextFactory
클래스를 하나 만들어주었다. 이를 통해 SecurityContext를 임의로 만들어주었고, 이를 적용한 Annotation을 하나 만들었다. 코드는 아래와 같다.
WithMockCustomAccountSecurityContextFactory
public class WithMockCustomAccountSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomAccount> {
@Override
public SecurityContext createSecurityContext(WithMockCustomAccount annotation) {
// 1
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 2
Map<String, Object> attributes = new HashMap<>();
attributes.put("username", annotation.username());
attributes.put("loginId", annotation.loginId());
attributes.put("password", annotation.password());
Member member = new Member((String) attributes.get("username"),
(String) attributes.get("loginId"),
(String) attributes.get("password")
);
// 3
UserDetailsImpl principal = new UserDetailsImpl(member);
// 4
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
principal,
principal.getAuthorities(),
Collections.emptyList());
// 5
context.setAuthentication(token);
return context;
}
}
WithMockCustomAccount
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomAccountSecurityContextFactory.class)
public @interface WithMockCustomAccount {
String username() default "username";
String loginId() default "loginId";
String password() default "password";
}
@ExtendWith(MockitoExtension.class)
class BoardServiceImplTest {
@InjectMocks
BoardServiceImpl boardService;
@Mock
BoardRepository boardRepository;
@Mock
MemberRepository memberRepository;
@Mock
CommentRepository commentRepository;
@Test
void findPostAndComments() {
// given
Member member = new Member("username", "loginId", "password");
Post post = new Post(100L, "title", "content", MainCategory.FRAMEWORK, SubCategory.DJANGO);
post.setMember(member);
Comment comment = new Comment(100L, "comment-content");
comment.setMember(member);
comment.setPost(post);
// stub
when(boardRepository.findById(100L)).thenReturn(Optional.of(post));
when(memberRepository.findByUsername(member.getUsername())).thenReturn(Optional.of(member));
// when
PostAndCommentResponseDto dtoCorrect = boardService.findPostAndComments(100L, member.getUsername());
// then
assertThat(100L).isEqualTo(dtoCorrect.getId());
assertThat(1).isEqualTo(dtoCorrect.getComments().size());
assertThatThrownBy(() -> boardService.findPostAndComments(101L, member.getUsername())).isInstanceOf(CustomException.class);
}
}
Mockito의 Mock, InjectMocks 어노테이션을 사용하기 위해서 Mockito의 테스트 실행을 확장해주어야 한다.
보통 단위테스트에서는 격리된 단위 테스트 케이스를 작성하기 위해 의존하고 있는 다른 객체를 대체하여 미리 만들어진 응답 결과(canned answer)를 반환하게 해야 한다. 그래서 우리는 단위테스트에서 테스트 스텁을 사용한다. 쉽게 말해 스텁은 메소드의 결과를 미리 지정하는 것이다.
만일, 테스트에서 사용하지 않는 Stub이 있을 경우 Unnecessary stubbings detected.
에러가 발생했다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class BoardRepositoryTest {
@Autowired
BoardRepository boardRepository;
@Autowired
MemberRepository memberRepository;
@Autowired
CommentRepository commentRepository;
@Test
@DisplayName("ID & 게시글 작성자 일치여부 확인")
void findByIdAndMember_Username() {
// given
Member member = new Member("username", "loginId", "password");
memberRepository.save(member);
Post post = new Post("title1", "content1", MainCategory.FRAMEWORK, SubCategory.DJANGO);
post.setMember(member);
Post savedPost = boardRepository.save(post);
// when
Post foundPost = boardRepository.findByIdAndMember_Username(savedPost.getId(), "username")
.orElseThrow(() -> new CustomException(Error.NO_AUTHORITY_POST));
// then
assertThat(post.getTitle()).isEqualTo(foundPost.getTitle());
assertThatThrownBy(() -> boardRepository.findByIdAndMember_Username(savedPost.getId(), "wrong-username")
.orElseThrow(() -> new CustomException(Error.NO_AUTHORITY_POST)))
.isInstanceOf(CustomException.class);
}
}
Annotation for a JPA test that focuses only on JPA components.
오직 JPA 컴포넌트들에만 집중해서 JPA 테스트용 어노테이션이다.
Using this annotation will disable full auto-configuration and instead apply only configuration relevant to JPA tests.
JPA 테스트와 관련한 설정들에만 적용되고 전체 설정 파일들을 다 불러오지는 않는다. 맥락은 위에서 본 WebMvcTest와 비슷하다고 보면 될 것 같다.
By default, tests annotated with @DataJpaTest
are transactional and roll back at the end of each test. They also use an embedded in-memory database (replacing any explicit or usually auto-configured DataSource). The @AutoConfigureTestDatabase
annotation can be used to override these settings.
기본적으로 @DataJpaTest라는 어노테이션이 붙어 있으면 Transactional이고 각 테스트 종료 후 롤백이 된다. 여기서는 인메모리 데이터 베이스를 사용한다고 하는데 이것때문에 에러가 나기도 해서, 나는 실제 내가 사용하는 MySQL 메모리로 테스트 하기위해 적힌 대로 @AutoConfigureTestDatabase를 써서 replace.None 값으로 변경해주었다.
실제 연결된 DB(MySQL)을 통해 테스트를 진행하려고 하면 @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
을 붙여줘야 하는데 뭘 replace 하지 않겠다는 것인지 잘 몰라서 코드를 뜯어보았다.
우선 @DataJpaTest
를 보면 AutoConfigure를 해주는 부분이 상당히 많다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {
이 중에서 @AutoConfigureTestDatabase
이 어노테이션을 살펴보면, default 값이 `Replace.ANY로 되어 있는 것을 알 수 있다. 만일,
H2,
DERBY,
HSQLDB로 자동연결되고 싶지 않다면,
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)` 를 넣어주면 된다.
이해하기 쉽게 작성해주셔서 큰 도움 되었습니다!!