TMI
- 2023년 11월부 Springboot 2.x.x.의 지원이 종료됨
- 이제 Java 17 이상 Springboot 3.x.x만 사용 가능
- 다양한 테크블로그 게시물 등 다수의 자료가 무력화
- 구글링 시 참고
테스트코드란?
- 특정 메서드가 의도대로 올바르게 잘 실행되는지 검증하는 일련의 메서드 및 클래스를 의미
- Springboot에서는 JUnit, Mockito 등을 활용한 다양한 테스트코드가 존재
- Controller, Service, Repository 등 여러 계층에 대한 테스트를 진행할 수 있음
- Repository JPA가
- 파이어베이스는 JPA가 안됩니다.
- Repository
왜 필요한가?
- 개발은 절대 혼자 하는 것이 아님
- 팀원들에게 자신이 구현한 기능(작성한 코드)의 퀄리티를 보장하는 아주 기본적인 안전망
- 코드 개선 및 리팩토링 중 필연적으로 발생하는 크고 작은 버그들을 테스트코드가 감지할 수 있음
- 리팩토링을 했는데 테스트코드 실패 → 초기 설계 의도대로 동작하지 않는다는 의미
- 메서드 단위의 버그의 조기 발견 및 수습을 통한 개발 리소스 절약
- 팀원들이 테스트코드를 보며 어떤 기능이 어떻게 작동해야 하는지 이해하는 데 도움이 됨
JUnit
- Java 프로그래밍 언어용 Unit Test 프레임워크
- Unit test: 단위 테스트, 소스 코드의 특정 메서드(모듈)의 올바른 작동 여부를 검증하기 위한 테스트
- JUnit4, JUnit5가 대표적이며, 되도록이면 최신 버전인 JUnit5를 사용!
TDD
- Test-Driven Development
- 테스트하고 싶은 계층의 메서드 내부를 구현하지 않은 채 메서드의 인터페이스(반환값 및 매개변수)만 작성
- public MemberDto getUser(Long id) { return null;}
- Controller 등에서도 TDD를 채택하기도 하나, 비즈니스 로직이 가장 중요하기에 보통 Service
- 테스트코드를 먼저 다 작성한 후에 Service 메서드 내부를 채우는 개발 전략
- Service가 제대로 잘 작성되었다면 테스트코드가 모두 통과될 것이라고 가정
- 테스트코드 작성 실력이 충분한 시점에선 굉장히 세련된 개발이 가능
- 메서드의 인터페이스 작성
- 메서드의 테스트코드 작성
- 메서드 내부 실제 구현
- 테스트코드로 검증
- 예외나 충돌 등의 다양한 상황을 모두 케어할 수 있는 테스트코드 작성 역량이 요구됨
build.gradle
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.junit.jupiter:junit-jupiter-api'
application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/gdsc?useSSL=
true
&serverTimezone=UTC
- useSSL을 true로 바꾸어 줍시다.
UserServiceTest
실제 DB Repository를 통해 Springboot에서 Service 테스트코드를 작성해봅시다.
package com.example.gdsc; import com.example.gdsc.dto.MemberDto; import com.example.gdsc.repository.MemberRepository; import com.example.gdsc.service.MemberService; import com.example.gdsc.util.exception.BaseException; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest class MemberServiceTest { @Autowired private MemberService memberService; @Autowired private MemberRepository memberRepository; @BeforeEach // 매 테스트 시작 전마다 자동 호출 void setUp() { memberRepository.deleteAll(); } @AfterEach // 매 테스트 종료 후마다 자동 호출 void tearDown() { memberRepository.deleteAll(); } @DisplayName("회원가입 테스트") @Test void signUpTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); // when Long id = memberService.join(memberDto); // then assertEquals(memberDto.getName(), memberRepository.findById(id).get().getName()); } @DisplayName("회원가입 중복 감지 테스트 - USER_ALREADY_EXISTS") @Test void signUpFailTest() { // given MemberDto memberDto = MemberDto.of("test1", "email"); MemberDto memberDto2 = MemberDto.of("test1", "email"); // when memberService.join(memberDto); // then Assertions.assertThrows(BaseException.class, () -> { memberService.join(memberDto2); }); } @DisplayName("회원 조회 테스트") @Test void getMemberTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); Long id = memberService.join(memberDto); // when MemberDto findMemberDto = memberService.findOne(id); // then assertEquals(memberDto.getName(), findMemberDto.getName()); } @DisplayName("회원 조회 테스트 - USER_NOT_FOUND") @Test void getMemberFailTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); Long id = memberService.join(memberDto); Long wrongId = id + 1; // when Assertions.assertThrows(BaseException.class, () -> { memberService.findOne(wrongId); }); } @DisplayName("회원 목록 조회 테스트") @Test void getMemberListTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); MemberDto memberDto2 = MemberDto.of("test2", "email"); memberService.join(memberDto); memberService.join(memberDto2); // when List<MemberDto> members = memberService.findMembers(); // then assertEquals(2, members.size()); assertEquals(memberDto.getName(), members.get(0).getName()); assertEquals(memberDto2.getName(), members.get(1).getName()); } @DisplayName("회원 수정 테스트") @Test void updateMemberTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); Long id = memberService.join(memberDto); // when memberService.update(id, MemberDto.of("test2", "email2")); // then assertEquals("test2", memberRepository.findById(id).get().getName()); } @DisplayName("회원 삭제 테스트") @Test void deleteMemberTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); Long id = memberService.join(memberDto); // when memberService.delete(id); // then Assertions.assertThrows(BaseException.class, () -> { memberService.findOne(id); }); } }
- assertEquals(”예상값”, “실제값”): 예상과 실제가 동일하면 테스트 통과
- assertTrue(”값”): true면 테스트 통과
- assertNotNull(”값”): Null이 아니면 테스트 통과
- 그 외 다양한 assert~ 메서드들이 존재하므로 적재적소에 사용하도록 합시다.
- 실제 DB를 기반으로 한 ServiceTest의 단점
- 실제 DB에 접근하여 CRUD를 가하고 있음
- 실제 서비스 운영을 시작한 후에도 이 DB에 테스트코드를 돌릴 수 있을까?
- 그렇다고 그 짧디짧은 테스트코드 빌드 시간을 위해 테스트 DB를 따로 파고, properties에 매번 지정한 DB명이나 주소 등을 바꾸는 건 여간 귀찮은 게 아님
Mockito
- 실제 DB에 의존하지 않고 테스트코드를 작성할 수 있도록 모의(mock) 객체를 생성하고 이를 사용하는 데 크고 작은 도움을 주는 라이브러리이다.
- 주로 Unit Test에서 의존성을 가진 클래스(Service, Repository 등)를 테스트할 때 사용한다.
- 실제 클라이언트가 API 요청을 가해야 실행되는 Controller 계층 메서드의 테스트도 가능하다는 장점
@ExtendWith(MockitoExtension.class) class MemberMockServiceTest { @InjectMocks // 테스트할 Service private MemberService memberService; @Mock // 테스트할 Service가 의존하는 Repository (2개 의존 시 2개 작성해야 함) private MemberRepository memberRepository; }
- @ExtendWith(MockitoExtension.class): JUnit5 Test 프레임워크를 확장하는 MockitoExtension을 활성화하며, 이를 통해 Mockito 어노테이션을 사용하여 Mock 객체를 생성하고 주입할 수 있게 된다.
- @InjectMocks: UserService 객체를 외부에서 생성 및 주입하여, 테스트코드에서 사용할 수 있도록 만들어준다.
- @Mock: Mock 모의 객체를 생성하고 주입하며, 주로 테스트할 Service가 의존하는 Repository에 붙인다. 즉 MemberRepository 모의 객체를 생성하고 테스트코드에서 활용할 수 있도록 도와준다.
ServiceTest에서의 주요 Mockito 메서드
- given: 주입한 Mock 객체에 대해 특정 조건에서 특정 메서드를 실행하면 정해진 결과가 나온다고 가정(지정)할 수 있다. 이를 기반으로 "A일 때, B가 나오는가?" 방식의 테스트 작성이 가능하다.
- verify: 주입한 Mock 객체의 특정 메서드 호출 횟수나 반환값 등을 검증하기 위해 사용할 수 있다.
@DisplayName("회원가입 테스트") @Test void signUpTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); Member member = Member.createMember(memberDto.getName(), memberDto.getEmail()); member.setId(1L); given(memberRepository.save(any(Member.class))).willReturn(member); // 어떤 멤버 객체를 save하든 그 결과는 무조건 member가 반환된다고 가정한다. // when Long id = memberService.join(memberDto); // then assertEquals(1L, id); verify(memberRepository, times(1)).save(any(Member.class)); // save가 1번 콜되었는가 검증한다. }
- MemberService의 join 메서드는 내부적으로 MemberRepository의 save()를 호출한다.
- 따라서 Mockito를 통해 Repository의 save()의 반환값을 given으로 고정시킬 수 있다.
- Mockito에서 Repository의 save() 메서드는 DB가 아니기에 PK를 새겨주지 않는다. 즉 여전히 null이다.
- 따라서 setter를 통해 직접 PK를 설정해주거나, 애초에 PK를 활용한 테스트를 포함하지 않아야 한다.
UserMockServiceTest
package com.example.gdsc; import com.example.gdsc.domain.Member; import com.example.gdsc.dto.MemberDto; import com.example.gdsc.repository.MemberRepository; import com.example.gdsc.service.MemberService; import com.example.gdsc.util.api.ResponseCode; import com.example.gdsc.util.exception.CustomException; import org.junit.jupiter.api.Assertions; 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; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class MemberMockServiceTest { @InjectMocks private MemberService memberService; @Mock private MemberRepository memberRepository; @DisplayName("회원가입 테스트") @Test void signUpTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); Member member = Member.createMember(memberDto.getName(), memberDto.getEmail()); member.setId(1L); given(memberRepository.save(any(Member.class))).willReturn(member); // when Long id = memberService.join(memberDto); // then assertEquals(1L, id); verify(memberRepository, times(1)).save(any(Member.class)); } @DisplayName("회원가입 중복 감지 테스트 - USER_ALREADY_EXISTS") @Test void signUpFailTest() { // given MemberDto memberDto = MemberDto.of("test1", "email"); given(memberRepository.existsByName(any(String.class))).willReturn(true); // when Assertions.assertThrows(CustomException.class, () -> { memberService.join(memberDto); }); } @DisplayName("회원 조회 테스트") @Test void getMemberTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); Member member = Member.createMember(memberDto.getName(), memberDto.getEmail()); member.setId(1L); given(memberRepository.findById(any(Long.class))).willReturn(java.util.Optional.of(member)); // when MemberDto findMemberDto = memberService.findOne(1L); // then assertEquals(memberDto.getName(), findMemberDto.getName()); assertEquals(memberDto.getEmail(), findMemberDto.getEmail()); verify(memberRepository, times(1)).findById(any(Long.class)); } @DisplayName("회원 조회 테스트 - USER_NOT_FOUND") @Test void getMemberFailTest() { // given given(memberRepository.findById(1L)).willThrow(new CustomException(ResponseCode.USER_NOT_FOUND)); // when Assertions.assertThrows(CustomException.class, () -> { memberService.findOne(1L); }); } @DisplayName("회원 목록 조회 테스트") @Test void getMemberListTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); MemberDto memberDto2 = MemberDto.of("test2", "email"); given(memberRepository.findAll()).willReturn(List.of( Member.createMember(memberDto.getName(), memberDto.getEmail()), Member.createMember(memberDto2.getName(), memberDto2.getEmail()) )); // when List<MemberDto> members = memberService.findMembers(); // then assertEquals(2, members.size()); assertEquals(memberDto.getName(), members.get(0).getName()); assertEquals(memberDto2.getName(), members.get(1).getName()); verify(memberRepository, times(1)).findAll(); } @DisplayName("회원 수정 테스트") @Test void updateMemberTest() { // given MemberDto memberDto = MemberDto.of("test", "email"); MemberDto updateMemberDto = MemberDto.of("test2", "email2"); Member member = Member.createMember(memberDto.getName(), memberDto.getEmail()); Member updateMember = Member.createMember("test2", "email2"); member.setId(1L); given(memberRepository.findById(any(Long.class))).willReturn(java.util.Optional.of(member)); given(memberRepository.save(any(Member.class))).willReturn(updateMember); // when memberService.update(1L, updateMemberDto); // then assertEquals("test2", memberRepository.findById(1L).get().getName()); } @DisplayName("회원 삭제 테스트") @Test void deleteMemberTest() { // when memberService.delete(1L); // then verify(memberRepository, times(1)).deleteById(any(Long.class)); } }
참고) UserMockControllerTest
Mockito 결론
- Mock 객체를 사용하면 첫번째로 작성했던 테스트코드와 달리 데이터베이스에 영향을 주지 않는다.
- given으로 대표되는 가정 -> 결론을 바탕으로 한 깔끔한 테스트가 가능해지므로 재사용성이 늘어난다.
- verify 등 여러 편의성을 갖는 메서드를 통해 미세하게 결과를 검증할 수 있어 꼼꼼한 테스트에도 도움이 된다.
- controller 계층에 대한 간결한 테스트코드 작성 역시 지원한다는 점에서 테스트 범위를 확대할 수 있다.
- 다양한 Repository를 복잡하게 참조하는 메서드에 대한 정교한 테스트가 어려울 수 있다.
- 반환값이 Void인 메서드에 대한 테스트가 어려울 수 있다. (given 메서드는 Void 반환을 허용하지 않음)