프로젝트 “밍글”을 진행하면서 제한된 시간 내에 빠르게 개발을 진행하느라 테스트코드를 미처 작성하지 못했습니다.
하지만 요구사항과 비즈니스 로직은 계속해서 변화하기 마련입니다. 그럴 때마다 코드를 바꾸고 Postman으로 직접 매번 테스트를 돌리는 것은 매우 비효율적입니다. 또한, 하나의 기능을 바꾸더라도 그 기능이 다른 기능과 연관이 있을 시 연관된 모든 API를 다시 테스트 해보아야 합니다. 더 최악의 경우로는 어떠한 변경사항이 다른 기능에 영향이 있을 지 모르고 넘어가는 것입니다.
이와 같은 상황을 방지하고자 새로운 프로젝트에서는 Spring Boot + JUnit5 + Mockito + MockMvc를 이용하여 Unit test를 작성해보았습니다.
JUnit5는 Java 단위 테스트(Unit test)를 위한 테스팅 프레임워크입니다.
MockMvc 객체를 주입받아 perform(httpMethod(”/url”))로 실행합니다.
mockMvc.andExpect(), andDo(), andReturn()과 같은 메소드들로 응답 결과를 검증할 수 있습니다. ResultActions interface
에 있는 메소드들입니다.
andExpect()
: Allows expectations on the result of an executed request.
mockMvc.perform(get("/person/1")) // performs an HTTP GET request with the specified parameter
.andExpect(status().isOk()) //checks if the response status is 200 (HTTP OK status).
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.person.name").value("Jason")); //
andDo()
: Perform a general action.
mockMvc.perform(get("/post")).andDo(print());
andReturn()
: Return the result of the executed request for direct access to the results.
MvcResult result = mockMvc.perform(get("/post")
.param("category", String.valueOf(category))
.contentType(APPLICATION_JSON))
.andDo(print());
.andReturn();
int status = result.getResponse().getStatus();
assertEquals(200, status);
String responseContent = result.getResponse().getContentAsString();
String expectedContent = objectMapper.writeValueAsString(postList);
assertEquals(expectedContent, responseContent);
Mock 객체를 쉽게 만들고, 관리하고, 검증할 수 있는 방법을 제공하는 프레임워크입니다.
@MockBean
주요 메소드만 살펴보겠습니다. 더 자세한 사용법은 https://scshim.tistory.com/439 를 참고하면 될 것 같습니다.
when()
메소드를 사용해서 Mock 객체의 행동을 정의할 수 있습니다. (Stubbing 할 수 있다).그 예로, 특정한 값을 리턴하거나 예외를 던지게 할 수 있습니다.@Test
void when_thenReturn사용(){
when(memberService.findById(1L)).thenReturn(Optional.of(member));
assertEquals("ted@email.com",memberService.findById(1L).get().getEmail());
}
any()
메소드(Argument Matcher)를 이용하면, 모든 매개 변수에 대하여 같은 행동을 하는 Mock 객체를 만들 수 있습니다.@ExtendWith(MockitoExtension.class)
class StudyServiceTest {
@Test
void any사용(){
when(memberService.findById(any())).thenReturn(Optional.of(member));
assertEquals("ted@email.com",memberService.findById(1L).get().getEmail());
assertEquals("ted@email.com",memberService.findById(999L).get().getEmail());
}
}
verify()
메소드를 사용해 특정 메소드가 특정 매개변수로 몇 번 호출 되었는지 확인할 수 있습니다.@Test
void Study객체를_생성하면_notify_1번_호출(){
StudyService studyService = new StudyService(memberService, studyRepository);
Study study = new Study(10, "테스트");
when(memberService.findById(1L)).thenReturn(Optional.of(member));
when(studyRepository.save(study)).thenReturn(study);
studyService.createNewStudy(1L, study);
*// memberService에서 notify(study) 메소드가 한 번 호출 되었는지 확인*
verify(memberService, times(1)).notify(study);
💡 이 외에도 Mock 객체를 조작하면,
특정한 매개변수를 받은 경우 특정한 값을 리턴하거나 예외를 던지도록 만들 수 있다.
- How about some stubbing?
- Argument matchers
Void 메서드가 특정 매개변수를 받거나 호출된 경우 예외를 발생 시킬 수 있다.
- Subbing void methods with exceptions
메서드가 동일한 매개변수로 여러번 호출될 때 각기 다르게 행동하도록 조작할 수 있다.
- Stubbing consecutive calls
출처: https://scshim.tistory.com/439
이제 위의 프레임워크들을 활용해 게시물 생성 API에 대한 PostControllerTest, PostServiceTest, PostRepositoryTest 클래스를 작성해보겠습니다.
PostMock.java
public class PostMock {
static CreatePostRequest createPostRequest() {
return CreatePostRequest.builder()
.title("title")
.content("content")
.categoryId("1")
.build();
}
static CreatePostResponse createPostResponse() {
return CreatePostResponse.builder()
.postId(1L)
.memberId(1L)
.nickName("nickName")
.title("title")
.content("content")
.createdAt(LocalDateTime.now())
.build();
}
PostControllerTest.java
@WebMvcTest(PostController.class) //1
@ExtendWith(MockitoExtension.class) //2
public class PostControllerTest {
@Autowired
private MockMvc mockMvc; //3
@Autowired
private ObjectMapper objectMapper; //response를 json으로 바꿔주기위해 필요한 의존성
@MockBean
private PostService postService; //4. PostService의 Mock 객체
@Test
@DisplayName("게시물 생성 Controller")
void createPost() throws Exception {
//5. given
Long memberId = 1L;
CreatePostRequest request = createPostRequest();
CreatePostResponse response = createPostResponse();
//6. any() 쓰는 이유
when(postService.createPost(any(CreatePostRequest.class), eq(memberId))).thenReturn(response); //mock postService.createPost()
//7. LocalDateTime 트러블슈팅
objectMapper.registerModule(new JavaTimeModule()); //LocalDateTime
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// Serialize the request object to a JSON string
String requestJson = objectMapper.writeValueAsString(request);
String responseJson = objectMapper.writeValueAsString(response);
mockMvc.perform(post("/post")
.contentType(APPLICATION_JSON)
.param("memberId", String.valueOf(memberId))
.content(requestJson)) //put the converted Json string request
.andExpect(status().isOk()) // checks if the response status is 200 (HTTP OK status).
.andExpect(content().json(responseJson)) //8: checks if the response content is equal to the expected JSON content.
.andExpect(jsonPath("$.postId").exists()) //9: check each parameter
.andExpect(jsonPath("$.memberId").value(memberId))
.andExpect(jsonPath("$.nickName").value(response.getNickName()))
.andExpect(jsonPath("$.title").value(response.getTitle()))
.andExpect(jsonPath("$.content").value(response.getContent()))
.andExpect(jsonPath("$.createdAt").exists())
.andDo(print()); //prints the result details to the console
verify(postService).createPost(any(CreatePostRequest.class), eq(memberId)); //8
}
@WebMvcTest(테스트할 컨트롤러.class)
@ExtendWith(MockitoExtension.class)
@Autowired MockMvc mvc;
@MockBean
PostService postService;
when(postService.createPost(any(CreatePostRequest.class), eq(memberId))).thenReturn(response);
에서 any() 를 쓰는 이유LocalDateTime 형식 트러블슈팅
Response content expected:<{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":[2023,3,28,15,13,39,577115000]}> but was:<{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":"2023-03-28T15:13:39.577115"}>
Expected :{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":[2023,3,28,15,13,39,577115000]}
Actual :{"postId":1,"memberId":1,"nickName":"nickName","title":"title","content":"content","createdAt":"2023-03-28T15:13:39.577115"}
objectMapper.registerModule(new JavaTimeModule()); //LocalDateTime
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
검증 방법
1) 실제 Json Response와 objectMapper를 이용해 만들어놓은 expected Json Response을 직접 비교해 검증하는 방법
.andExpect(content().json(responseJson))
2) 각 Parameter 별로 비교해 검증하는 방법
.andExpect(jsonPath("$.postId").exists()) //9: check each parameter
.andExpect(jsonPath("$.memberId").value(memberId))
.andExpect(jsonPath("$.nickName").value(response.getNickName()))
.andExpect(jsonPath("$.title").value(response.getTitle()))
.andExpect(jsonPath("$.content").value(response.getContent()))
.andExpect(jsonPath("$.createdAt").exists())
8, 9번중 하나만 써도 되지만 교차 검증을 위해 둘 다 해주었습니다.
서비스 계층의 메소드를(게시물 생성) 테스트하기 위해선 다음과 같은 Service 클래스가 필요합니다.
@Service
@Transactional(readOnly = true)
@AllArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final MemberRepository memberRepository;
@Transactional //테스트 할 게시물 생성 메소드
public CreatePostResponse createPost(CreatePostRequest request, Long memberId) {
Member member = getMember(memberId);
Post savedPost = postRepository.save(Post.createPost(request, member));
return CreatePostResponse.builder()
.postId(savedPost.getId())
.memberId(savedPost.getMember().getId())
.nickName(savedPost.getMember().getNickname())
.title(savedPost.getTitle())
.content(savedPost.getContent())
.createdAt(savedPost.getCreatedAt())
.build();
}
이제 ServiceTest를 작성 해보겠습니다.
@SpringBootTest //1
@Transactional //2
@ExtendWith(MockitoExtension.class) //3
public class PostServiceTest {
@Autowired //4
private PostService postService;
@MockBean
private PostRepository postRepository; //5
@MockBean
private MemberRepository memberRepository; //5
private Member member;
@BeforeEach
@Test
void setUp() { //6
member = Member.createTestMember(createMemberRequest()); //7. set memberId=1L
}
}
public static Member createTestMember(CreateMemberRequest request) {
Member member = new Member();
member.setId(1L);
member.setNickname(request.getNickName());
member.setEmail(request.getEmail());
member.setPassword(request.getPwd());
member.setCreatedAt(LocalDateTime.now());
member.setUpdatedAt(LocalDateTime.now());
member.setStatus(Status.ACTIVE);
member.setRole(request.getRole());
return member;
}
static CreateMemberRequest createMemberRequest() {
return CreateMemberRequest.builder()
.nickName("nickName")
.email("email")
.pwd("pwd")
.role(MemberRole.MENTEE)
.build();
}
이제 게시물 생성 테스트코드를 보겠습니다.
@Test
void createPost() {
//given
CreatePostRequest request = createPostRequest();
Post expectedPost = Post.createTestPost(request, member);
when(memberRepository.findById(member.getId())).thenReturn(Optional.of(member)); //mock postRepository.findById()
when(postRepository.save(any(Post.class))).thenReturn(expectedPost); //mock postRepository.save()
//when
CreatePostResponse response = postService.createPost(request, member.getId());
//then
assertEquals(1L, response.getPostId());
assertEquals(member.getId(), response.getMemberId());
assertEquals(member.getNickname(), response.getNickName());
assertEquals(request.getTitle(), response.getTitle());
assertEquals(request.getContent(), response.getContent());
verify(memberRepository).findById(member.getId());
verify(postRepository).save(any(Post.class));
}
given
when
then
마지막으로, repository 계층 단위 테스트를 작성해보겠습니다.
@DataJpaTest //1
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) //2
public class PostRepositoryTest {
@Autowired
private PostRepository postRepository;
private Member member;
@BeforeEach
@Test
void createMember() { //3
member = Member.createTestMember(createMemberRequest());
}
}
스프링 부트는 JPA 레포지토리를 손쉽게 테스트할 수 있는 @DataJpaTest 어노테이션을 제공하고 있습니다. @DataJpaTest를 사용하면 기본적으로 인메모리 데이터베이스인 H2를 기반으로 테스트용 데이터베이스를 구축하며, 테스트가 끝나면 트랜잭션 롤백을 해줍니다.
레포지토리 계층은 실제 DB와 통신없이 단순 모킹하는 것은 의미가 없으므로 직접 데이터베이스와 통신하는 @DataJpaTest를 사용하였습니다.
Repository 타입의 빈을 등록하기 위해서는 @Repository 어노테이션을 붙여주어야 한다. 하지만 JpaRepository 하위에 @Repository가 이미 붙어있으므로 @Repository를 붙여주지 않아도 된다.
또한 테스트를 위해서는 테스트 컨텍스트를 잡아주어야 할텐데, @DataJpaTest는 내부적으로 @ExtendWith( SpringExtension.class) 어노테이션을 가지고 있어서, 이 어노테이션만 붙여주면 된다.
마찬가지로 @DataJpaTest에는 @Transactional 어노테이션이 있어서, 테스트의 롤백 등을 위해 별도로 트랜잭션 어노테이션을 추가하지 않아도 된다.
만일 h2가 아닌 실제 DB(MySql)와 연결해 테스트를 해보고싶다면 위 어노테이션을 추가해주어야 합니다.
이제 유닛 테스트 코드를 보겠습니다.
@Test
@DisplayName("게시물 저장")
void savePost() {
//given
Member member = Member.createTestMember(createMemberRequest()); //memberId = 1L
Post post = Post.createPost(createPostRequest(), member);
//when
Post savedPost = postRepository.save(post);
//then
assertNotNull(savedPost);
assertNotNull(savedPost.getId());
assertEquals(post.getMember(), savedPost.getMember());
assertEquals(post.getTitle(), savedPost.getTitle());
assertEquals(post.getContent(), savedPost.getContent());
assertEquals(post.getCategory(), savedPost.getCategory());
assertNotNull(savedPost.getCreatedAt());
assertEquals(member, savedPost.getMember());
}
given
when
then
Spring Boot + JUnit5 + Mockito + MockMvc 로 각 계층별 Unit test를 작성해보았습니다.
앞으로 밍글에서도 새로운 기능을 도입할 때 단위 테스트, 그리고 여유가 된다면 꼭 TDD로 개발을 하는 습관을 들여야겠다고 생각한 계기가 되었습니다.
출처
좋은 글 잘 보고 갑니다!