프로젝트 요구사항에 단위 테스트에 대한 요구사항이 있었다.
오늘 팀원들과 함께 스프링 부트로 만든 게시판 예제를 활용해서 단위테스트를 정리해 봤다.
스프링의 비즈니스 로직은 크게 Controller
, Service
, Repository
에 존재하기 때문에 이 세 가지에 대한 단위 테스트를 정리하려고 한다.
테스트 환경
- Java 11
- Spring boot 2.7.13
- Spring Data JPA
- JUnit 5.8.2
- H2 2.1.214
Repository
우리 팀은 JPA를 사용할 예정이기 때문에 @DataJpaTest
를 사용해서 테스트를 하려고 한다.
아직 복잡한 비즈니스로직이 없기 때문에 간단하게 하겠다.
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
class PostRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Autowired
PostRepository postRepository;
@DisplayName("Post 저장")
@Test
void testFindById() {
Member member = new Member("name", "email", "password");
memberRepository.save(member);
Post post = new Post("title", "content", member);
Post save = postRepository.save(post);
assertThat(save.getId()).isEqualTo(post.getId());
}
}
Post를 save 하고 다시 조회하는 로직이다. Post는 Member의 외래키를 가지고 있기 때문에 영속성 컨텍스트에서 관리되는 Member를 넣어줘야 하기 때문에 우선 DB에 저장하고, Post 생성 시 다시 넣어줬다.
결과를 보면 Member save 시 한 번, Post save 시 한 번 총 2회 insert query가 발생했다. 또한, 테스트가 종료되고 Rollback이 되어 DB가 초기화되었다.
@DataJpaTest
에 @Transactional
가 함께 있기 때문에 정상적으로 Rollback이 되었다.
MySQL 같은 경우에는 auto-increment
속성 추가 시 Rollback 되어도 이때 만들어진 PK값 이후로 다시 만들어지니 조심해야 한다.
예를 들어 이번 테스트에서 생성된 post의 ID가 2라고 할 때, Rollback 되었다고 해서 운영환경에서 다음 post의 ID가 2가 아닌 3부터 채워지므로 테스트에 하드코딩된 ID를 넣어뒀다면 추후에 오류가 생길 수 있다는 점을 조심해야 한다.
Service
Production Code
@Transactional(readOnly = true)
@Service
public class PostServiceImpl implements PostService {
private final PostRepository postRepository;
private final MemberRepository memberRepository;
public PostServiceImpl(PostRepository postRepository, MemberRepository memberRepository) {
this.postRepository = postRepository;
this.memberRepository = memberRepository;
}
@Transactional
@Override
public PostResponse createPost(
final MemberSession memberSession,
final PostRequest postRequest) {
Member member = memberRepository.findById(memberSession.getId())
.orElseThrow(MemberNotFoundException::new);
Post post = Post.builder()
.member(member)
.title(postRequest.getTitle())
.content(postRequest.getContent())
.build();
Post savedPost = postRepository.save(post);
return PostResponse.fromEntity(savedPost);
}
}
Test Code
import static org.assertj.core.api.Assertions.*;
import static org.mockito.BDDMockito.*;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class PostServiceImplTest {
@Mock
PostRepository postRepository;
@Mock
MemberRepository memberRepository;
@InjectMocks
PostServiceImpl postService;
@DisplayName("게시물 생성")
@Test
void testCreatePost() {
Long id = 1L;
String title = "타이틀";
String content = "컨텐트";
MemberSession memberSession = mock(MemberSession.class);
PostRequest postRequest = mock(PostRequest.class);
Member member = mock(Member.class);
given(memberSession.getId()).willReturn(id);
given(postRequest.getTitle()).willReturn(title);
given(postRequest.getContent()).willReturn(content);
given(memberRepository.findById(id)).willReturn(Optional.of(member));
Post post = Post.builder()
.member(member)
.title(postRequest.getTitle())
.content(postRequest.getContent())
.build();
given(postRepository.save(any(Post.class))).willReturn(post);
PostResponse resp = postService.createPost(memberSession, postRequest);
assertThat(resp.getTitle()).isEqualTo(title);
assertThat(resp.getContent()).isEqualTo(content);
then(memberRepository).should(times(1)).findById(id);
then(postRepository).should(times(1)).save(any(Post.class));
}
}
JUnit
과 Mockito
를 연동할 때 필요한 @ExtendWith(MockitoExtension.class)
를 클래스 레벨에 선언했다.
우선 PostService
와 의존관계에 있는 MemberRepository
, PostRepository
를 @Mock
을 이용해 선언했다.
또한, @InjectMocks
을 활용해 Mocking 한 의존성을 PostService
에 주입하고 본격적으로 테스트 작성을 시작한다.
production code의 createPost 메서드의 파라미터를 mocking을 하고 given
메서드를 활용해 행위를 설정해 준다.
또한, mocking 된 의존성들인 memberRepository
와 postRepository 또한
행위를 설정해 준 후에 테스트하려는 createPost
를 동작시킨다.
우리가 설정한 값으로 확인할 수 있는 title, content를 assertThat
을 활용해 확인하고 mocking 한 의존성 클래스들이 동작했는지 then
메서드를 활용해서 확인했다.
Service
클래스의 단위 테스트는 따로 DB를 활용하지 않았기 때문에 테스트를 성공한다면 콘솔에 따로 뜨는 것은 없다.
Controller
Production Code
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity<CommonResponse<MemberResponse>> signup(@RequestBody @Valid SignupRequest signupRequest) {
MemberResponse signup = memberService.signup(signupRequest);
return CommonResponse.success(HttpStatus.OK, 200, signup);
}
}
Test Code
import static java.nio.charset.StandardCharsets.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.BDDMockito.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.times;
import static org.springframework.http.MediaType.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@WebMvcTest(MemberController.class)
class MemberControllerTest {
MockMvc mockMvc;
@Autowired
MemberController memberController;
@MockBean
MemberService memberService;
@Autowired
ObjectMapper om;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
.standaloneSetup(memberController)
.alwaysDo(print())
.build();
}
@DisplayName("회원가입")
@Test
void testSignup() throws Exception {
Long id = 1L;
String email = "email@email.com";
String name = "홍길동";
String password = "password";
MemberResponse memberResponse = new MemberResponse(id, email, name);
DummySignupRequest dummySignupRequest = new DummySignupRequest(email, password, name);
String requestBody = om.writeValueAsString(dummySignupRequest);
// System.out.println(om.writerWithDefaultPrettyPrinter().writeValueAsString(dummySignupRequest));
given(memberService.signup(any(SignupRequest.class))).willReturn(memberResponse);
mockMvc.perform(
post("/members/signup")
.characterEncoding(UTF_8)
.contentType(APPLICATION_JSON).
content(requestBody))
// assertThat
.andExpect(status().isOk())
.andExpect(jsonPath("$.code", is(HttpStatus.OK.value())))
.andExpect(jsonPath("$.data.id", is(id), Long.class))
.andExpect(jsonPath("$.data.email", is(email)));
then(memberService).should(times(1)).signup(any(SignupRequest.class));
}
}
Controller 테스트는 스프링 프레임워크가 받은 요청을 처리해줘야 해서 Service나 Repository의 테스트와 설정이 조금 다른 것 같다.@WebMvcTest
내부의 @ExtendWith(SpringExtension.class)
는 Spring Context와 JUnit을 사용할 때 필요한 Annotation이라고 한다.
또한, @BeforeEach
는 추후 테스트 할 메서드가 더 늘어날 때 각각의 테스트 동작 이전에 실행된다.
여기서 나는 mockMvc
를 standAloneSetup
을 활용해 초기화해주었는데, 이 것은 Unit test를 위한 설정이다. 추후 통합 테스트를 하기 위해서는 webAppContextSetup()
메서드를 활용해 초기화해 주자.
또한, alwayDo(print())는 요청의 상세 내용과, 응답의 상세 내용을 콘솔에 출력해준다.
MockHttpServletRequest:
HTTP Method = POST
Request URI = /members/signup
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"68"]
Body = {"email":"email@email.com","password":"password","name":"홍길동"}
Session Attrs = {}
Handler:
Type = com.woorifisa.board.domain.member.controller.MemberController
Method = com.woorifisa.board.domain.member.controller.MemberController#signup(SignupRequest)
Async:
Async started = false
Async result = null
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"code":200,"data":{"id":1,"email":"email@email.com","name":"홍길동"}}
Forwarded URL = null
Redirected URL = null
Cookies = []
스프링이 받는 Http 요청은 문자열로 이루어져 있다. 따라서 Http Body는 JSON 형식의 문자열이다. 이 문자열을 스프링이 파싱 해서 객체로 바꿔주는 것이다.
우리가 설정한 DTO는 직렬화를 위한 기본생성자와 getter
메서드만 있다.
@NoArgsConstructor
@Getter
public class SignupRequest {
@Email
@NotBlank
private String email;
@NotBlank
private String password;
@NotBlank
private String name;
}
프로덕션 코드를 해치지 않기 위해 테스트 패키지에 필드명이 같은 더미 클래스를 만들고, 이 클래스를 생성해 스프링의 JSON 관리 클래스인 ObjectMapper
로 JSON 형식의 String으로 변환했다.
{
"email" : "email@email.com",
"password" : "password",
"name" : "홍길동"
}
주석의 writerWithDefaultPrettyPrinter()
를 활용하면 JSON을 포맷팅해서 보여준다. 콘솔에 찍을 때 유용하다.
이후에 mockMvc
의 perform
메서드를 활용해 요청을 보낸다.
헤더에 인코딩과 request body의 형식을 지정하고, 우리가 String으로 만든 실제 Request Body를 넣어준다.
그리고 andExpect를 활용해 asserThat과 같이 우리의 결과를 검증할 수 있다.
간단하게 Http Status와 jsonPath
를 활용해 response body를 체크했다.
jsonPath
의 문법은 다양하기 때문에 필요할 때 찾아 쓰는 것을 추천한다.
andExpect(jsonPath("$.data.id", is(id), Long.class))
여기서 Long.class
를 추가해 준 이유는 id는 Long타입인데, Long 타입은 L
이 뒤에 붙어서 그런지 파라미터에 Long.class 없이
사용 시 오류가 났다.
정리
Spring boot 사용 시 가장 많이 쓰이는 3개의 Layer에 대한 테스트를 간단히 알아봤다.
테스트 할 때 은근 신경쓰이는 것이 어떤 패키지의 메서드를 import 해야하나 인 것 같다.
import 부분도 함께 작성했으니 참고하면 좋을 것 같다.
테스트 코드를 작성하는 것이 처음에는 많이 어렵고 어색하겠지만, 본인이 작성한 시나리오를 잘 따라간다면 분명 좋은 테스트 코드를 작성할 수 있을 것이다.
내가 짠 코드가 정답이 아닐 수도 있고, 다른 다양한 상황에서 테스트를 작성해야 할 것이다.
하지만 이 예제를 바탕으로 다양한 상황에서 많은 테스트 코드를 작성하고, 더 나아가 TDD에 도전해 보면 좋을 것이다.
테스트 코드를 통해 더 안정적인 서버 애플리케이션을 개발하면 좋을 것 같다.
'우리 FISA' 카테고리의 다른 글
우리FISA 클라우드 서비스 개발 - Spring Boot 어플리케이션에서 RDS MySQL 이중화 (1) | 2023.08.29 |
---|---|
우리FISA 클라우드 서비스 개발 - 프로젝트 시작 전 CI/CD 파이프라인 만들기 (0) | 2023.08.20 |
우리FISA 클라우드 서비스 개발 - 프로젝트 시작과 프로젝트 산출물 (0) | 2023.08.13 |
우리FISA 클라우드 서비스 개발 - EC2, Docker, Jenkins를 사용한 Spring-boot 어플리케이션 배포 (0) | 2023.07.30 |
댓글