Discover us

About us

Projects

Blog

Events

Members

Development Blog

GDGoC CAU 개발자와 디자이너의 작업 과정과
결과물을 공유하는 공간입니다.

어떻게 프로젝트를 시작하게 되었고,
진행하면서 느낀 개발자와 디자이너의
생생한 스토리를 직접 확인해보세요!

Development

Springboot 테스트코드 작성

  • #Back-End
  • Seungwoon Chae
  • 2023. 11. 29.

Springboot 테스트코드 작성

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가 제대로 잘 작성되었다면 테스트코드가 모두 통과될 것이라고 가정
  • 테스트코드 작성 실력이 충분한 시점에선 굉장히 세련된 개발이 가능
      1. 메서드의 인터페이스 작성
      1. 메서드의 테스트코드 작성
      1. 메서드 내부 실제 구현
      1. 테스트코드로 검증
  • 예외나 충돌 등의 다양한 상황을 모두 케어할 수 있는 테스트코드 작성 역량이 요구됨

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 반환을 허용하지 않음)

참고