유닛 테스트(unit test)는 컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차다.
즉, 모든 함수와 메소드에 대한 테스트 케이스(Test case)를 작성하는 절차를 말한다.
이를 통해서 언제라도 코드 변경으로 인해 문제가 발생할 경우, 단시간 내에 이를 파악하고 바로 잡을 수 있도록 해준다.
이상적으로, 각 테스트 케이스는 서로 분리되어야 한다. 이를위해 가짜 객체(Mock object)를 생성하는 것도 좋은 방법이다.
- 위키백과
배경
현재 내가 일하고 있는 조직에서는 테스트 코드 작성을 하지 않는다. 이로 인해 코드 수정이나 기능을 추가해야 할 때 수동으로 테스트를 진행해야 했다. 작은 변경사항에도 전체 기능을 다시 테스트해야 하는 상황이 자주 발생했고, 이는 상당히 소모적이라고 느껴졌다.
이런 경험을 통해 테스트 코드의 중요성을 더욱 실감하게 되었다. 테스트 코드가 있었다면 변경사항 검증이 더 효율적이고 신뢰할 수 있었을 거라고 생각한다. 이후 혼자 진행하는 프로젝트를 맡게 되었다. 테스트 코드를 작성하면서 개발을 하고싶었지만 일정에 맞춰 빠르게 개발을 해야 했기 때문에 테스트 코드를 작성하지 않고 개발을 진행했다. 이후 Mybatis에서 JPA로 마이그레이션하는 작업을 하면서 단위 테스트를 작성하기로 결정했다.
이번 프로젝트를 진행하면서 계층별 (Controller, Service, Repository) 단위 테스트 했던 내용을 정리하려고 한다.
본 글에서는 JUnit, Mockito를 사용한 Service 계층 단위 테스트에 대해 다룬다.
잘못된 내용이 있거나 더 좋은 코드를 알고계신다면 마음껏 댓글에 남겨주세요.
개발 환경
- Java 8
- Spring Boot 2.x
- Gradle
- Spring Data JPA
- JSP
- JUnit, Mockito
- H2
- IntelliJ
구현
Gradle
dependencies {
...
// Testing
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mockito:mockito-inline:5.2.0'
test {
useJUnitPlatform()
}
...
}
- org.springframework.boot:spring-boot-starter-test : Spring Boot 테스트를 위한 의존성. JUnit, AssertJ, Hamcrest, Mockito 등 테스트에 필요한 다양한 라이브러리가 포함되어 있다.
- org.mockito:mockito-inline:5.2.0 : final 클래스, static 메서드는 모킹할 수 있도록 해준다. static 메서드를 모킹할 필요가 없다면 의존할 필요 없다. 관련 내용은 별도 글을 작성했다. (링크)
- useJUnitPlatform() : JUnit5를 사용할 수 있도록 해준다. JUnit5는 자바를 위한 단위 테스트 프레임워크이다. 람다를 사용하기 때문에 Java8 이상부터 지원하고 있다. 테스트를 위한 편리한 기능들을 제공해준다.
Service
단위테스트를 진행할 Service 클래스다. 예시를 위해 하나의 메서드만 남겨두었다.
deleteTasks() 메서드는 전달받은 업무 ID 리스트로 해당 업무들을 삭제해주는 역할을 한다.
@Slf4j
@Service
@Primary
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class JpaTaskManagerServiceImpl extends AbstractTaskManagerService {
private final JpaTaskManagerRepository taskManagerRepository;
private final JpaEvaluationRepository evaluationRepository;
@Override
@Transactional
public void deleteTasks(List<TaskRequestDto> taskRequestDtos) {
List<Long> taskIdList = taskRequestDtos.stream().mapToLong(TaskRequestDto::getTaskId).boxed().collect(Collectors.toList());
if (evaluationRepository.existsByIdTaskIdIn(taskIdList)) {
throw new BusinessLogicException(ExceptionCode.ALREADY_EXISTS_MAPPING);
}
taskManagerRepository.removeAllByIdList(taskIdList);
}
...
}
Test
Service Test 클래스이다. 2개의 테스트 케이스가 있는데, 첫 번째 케이스는 삭제하려는 업무에 담당자가 매핑되어 있을 때 예외가 잘 발생하는지 테스트하고 있다. 두 번째 케이스는 담당자가 매핑되어 있지 않으면 삭제 프로세스가 정상적으로 동작해서 deleteAllById() 메서드를 잘 호출하는지 검증한다.
Service 단위 테스트이기 때문에 Repository의 내부 동작은 검증 대상이 아니다. Service의 역할이 잘 수행되는지만 확인하면 된다. 즉, 주어진 조건에 따라 적절한 예외를 발생시키는지, 필요한 Repository 메서드를 올바르게 호출하는지 등을 검증하면 되는 것이다.
아래 테스트 코드를 부분별로 나누어 살펴보자
@ExtendWith(MockitoExtension.class)
class JpaTaskManagerServiceImplTest {
@InjectMocks
private JpaTaskManagerServiceImpl jpaTaskManagerServiceImpl;
@Mock
private JpaTaskManagerRepository taskManagerRepository;
@Mock
private JpaEvaluationRepository evaluationRepository;
...
@Test
@DisplayName("이미 매핑된 업무는 삭제할 수 없습니다.")
void deleteTasks() {
// given
List<TaskRequestDto> taskRequestDtoList = TestUtils.createDummyTaskRequestDtoList();
given(evaluationRepository.existsByIdTaskIdIn(Mockito.anyList())).willReturn(true);
// when & then
assertThatThrownBy(() -> jpaTaskManagerServiceImpl.deleteTasks(taskRequestDtoList))
.isInstanceOf(BusinessLogicException.class)
.hasMessage(ExceptionCode.ALREADY_EXISTS_MAPPING.getMessage());
}
@Test
@DisplayName("업무 정보를 삭제합니다.")
void deleteTasks2() {
// given
List<TaskRequestDto> taskRequestDtoList = TestUtils.createDummyTaskRequestDtoList();
given(evaluationRepository.existsByIdTaskIdIn(Mockito.anyList())).willReturn(false);
// when
jpaTaskManagerServiceImpl.deleteTasks(taskRequestDtoList);
// then
Mockito.verify(taskManagerRepository, Mockito.times(1)).removeAllByIdList(Mockito.anyList());
}
}
@ExtendWith(MockitoExtension.class)
class JpaTaskManagerServiceImplTest {
@InjectMocks
private JpaTaskManagerServiceImpl jpaTaskManagerServiceImpl;
@Mock
private JpaTaskManagerRepository taskManagerRepository;
@Mock
private JpaEvaluationRepository evaluationRepository;
- @ExtendWith(MockitoExtention.class): JUnit5의 확장 기능으로, Mockito를 테스트에서 사용할 수 있도록 한다. 이를 통해 @Mock, @InjectMocks 애너테이션을 사용할 수 있다.
- @InjectMocks: Mockito가 @InjectMocks 애너테이션이 붙은 객체에 Mock 객체들을 자동으로 주입한다.
- @Mock: @Mock 애너테이션이 붙은 객체의 Mock 객체를 생성하고 @InjectMocks 애너테이션이 붙은 객체에 자동으로 주입된다. 이제 JpaTaskManagerServiceImpl에서 JpaTaskManagerRepository와 JpaEvaluationRepository는 실제 객체가 아닌 Mock객체가 사용된다.
@Test
@DisplayName("이미 매핑된 업무는 삭제할 수 없습니다.")
void deleteTasks() {
// given
List<TaskRequestDto> taskRequestDtoList = TestUtils.createDummyTaskRequestDtoList();
given(evaluationRepository.existsByIdTaskIdIn(Mockito.anyList())).willReturn(true);
// when & then
assertThatThrownBy(() -> jpaTaskManagerServiceImpl.deleteTasks(taskRequestDtoList))
.isInstanceOf(BusinessLogicException.class)
.hasMessage(ExceptionCode.ALREADY_EXISTS_MAPPING.getMessage());
}
@Test
@DisplayName("업무 정보를 삭제합니다.")
void deleteTasks2() {
// given
List<TaskRequestDto> taskRequestDtoList = TestUtils.createDummyTaskRequestDtoList();
given(evaluationRepository.existsByIdTaskIdIn(Mockito.anyList())).willReturn(false);
// when
jpaTaskManagerServiceImpl.deleteTasks(taskRequestDtoList);
// then
Mockito.verify(taskManagerRepository, Mockito.times(1)).removeAllByIdList(Mockito.anyList());
}
}
- given-when-then 패턴과 given().willReturn() 메서드는 이전 Controller 단위 테스트 글에서 다뤘기 때문에 생략한다. (참고)
- Assertions.assertThat(): 테스트 검증에 필요한 모든 메서드가 모여있는 클래스이다. Jupiter, Hamcrest, AssertJ에서 지원하는 Assertions가 있고 입맛에 맞게 사용하면 될 듯 하다.(AssertJ가 많이 선호되는 듯 하다.) 예시 외의 검증 메서드가 아주 많으니 직접 공식 문서를 통해 학습하도록 하자
// 두 값이 같은지 검증
assertEquals(expected, actual);
// 조건이 true인지 검증
assertTrue(1 == 1);
// 조건이 false인지 검증
assertFalse(1 == 2);
// 객체가 null인지 건증
assertNull(null);
// 객체가 null이 아닌지 검증
assertNotNull("not null");
// 특정 코드가 예외를 발생시키는지 검증. 메서드 체이닝을 통해 추가 검증을 할 수 있다.
assertThatThrownBy(() -> jpaLoginManagerServiceImpl.login(session, loginRequestDto))
.isInstanceOf(BusinessLogicException.class)
.hasMessage(ExceptionCode.INVALID_ID_OR_PASSWORD.getMessage());
/**
assertAll 내부의 모든 검증을 실행하고 실패한 모든 검증 정보를 알려준다.
assertAll을 사용하지 않고 여러개의 검증을 실행할 경우, 첫 번째 실패 검증이 나오면 검증이 중단되며 이후 검증은 실행되지 않는다.
*/
assertAll(
() -> assertEquals("김찬빈", "빈찬김"),
() -> assertEquals("chanbin", "CHANBIN".toLowerCase()),
() -> assertNull(object)
);
- Mockito.verify(): Mock 객체의 특정 메서드가 호출되었는지, 어떻게 호출되었는지 검증하는 메서드이다.
// mockObject의 methodName()메서드가 n번 호출되었는지 검증
verify(mockObject, times(n)).methodName();
// mockObject의 methodName() 메서드가 최소 한 번 호출되었는지 검증
verify(mockObject, atLeastOnce()).methodName();
// mockObject의 methodName() 메서드가 최소 n번 이상 호출되었는지 검증
verify(mockObject, atLeast(n)).methodName();
// mockObject의 methodName() 메서드가 최대 n번 이하 호출되었는지 검증
verify(mockObject, atMost(n)).methodName();
// mockObject의 mehtod가 한 번도 호출되지 않았는지 검증
verify(mockObject, never()).methodName();
// mockObject의 methodName() 메서드가 정확히 "expected"라는 인자로 호출되었는지 검증
verify(mockObject).methodName("expected");
// mockObject의 methodName() 메서드가 어떤 String 타입의 인자로든 호출되었는지 검증
verify(mockObject).methodName(anyString());
// mockObject 전체와 상호작용이 발생하지 않았는지 검증
verifyNoMoreInteractions(mockObject);
// 특정 시간 내에 메서드가 호출되었는지 검증
verify(mockObject, timeout(n)).methodName();
테스트 결과
테스트가 무사히 통과되었다.
Controller, Repository 단위 테스트가 궁금하다면 아래 링크를 통해 확인할 수 있다.
참조
https://javadoc.io/doc/org.assertj/assertj-core/latest/index.html
https://junit.org/junit5/docs/current/user-guide/#overview-what-is-junit-5
https://howtodoinjava.com/spring-boot/spring-boot-test-controller-service-dao/
https://www.javaguides.net/2022/03/spring-boot-unit-testing-service-layer.html
https://www.baeldung.com/mockito-junit-5-extension
https://www.diffblue.com/resources/how-to-write-better-unit-test-assertions/
'Spring' 카테고리의 다른 글
Spring Boot - Repository 단위 테스트하기(JPA, Querydsl, Mybatis) (0) | 2024.12.31 |
---|---|
Spring Boot - Controller 단위 테스트하기(JUnit, MockMvc, Mockito) (5) | 2024.12.13 |
Spring Boot - JPA 스키마, 데이터 초기화하기 (SQL script -> ddl-auto) (1) | 2024.11.13 |
Spring Boot Test - Mockito로 Static Method Mock 만드는 방법 (2) | 2024.11.01 |
Spring Interceptor - 인터셉터로 로그인 체크하기 (0) | 2023.09.12 |