Updated - 2024. 12. 08
이 글에서는 REST-API를 단위 테스트 코드를 작성하는 방법에 대해 알아보겠습니다.
01. REST-API 방명록 작성
먼저 방명록 예제를 최대한 간단하게 작성하겠습니다.
Gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
/* h2 */
runtimeOnly 'com.h2database:h2'
/* Lombok */
implementation 'org.projectlombok:lombok:1.18.36'
annotationProcessor 'org.projectlombok:lombok:1.18.36'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
- Lombok을 사용하였습니다.
- DB는 H2를 사용하였습니다.
Application.yml
spring:
application:
name: guestbook
datasource:
url: jdbc:h2:mem:guestbook
driverClassName: org.h2.Driver
username: admin
password: password
jpa:
database-platform: org.hibernate.dialect.H2Dialect
h2:
console:
enabled: true
- H2 접속 정보
- http://localhost:8080/h2-console 경로에서 테이블 확인할 수 있습니다.
Controller
import com.example.guestbook.domain.entity.GuestbookEntity;
import com.example.guestbook.dto.GuestbookDto;
import com.example.guestbook.service.GuestbookService;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@AllArgsConstructor
@RestController
@RequestMapping("/api/guestbook")
public class GuestbookController {
private final GuestbookService service;
@GetMapping
public List<GuestbookEntity> getAllGuestbooks() {
return service.findAll();
}
@GetMapping("/{id}")
public GuestbookEntity getGuestbookById(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
public GuestbookEntity createGuestbook(@RequestBody GuestbookDto guestbook) {
GuestbookEntity createGuestbook = GuestbookEntity.builder()
.name(guestbook.getName())
.message(guestbook.getMessage())
.build();
return service.save(createGuestbook);
}
@PutMapping("/{id}")
public GuestbookEntity updateGuestbook(@PathVariable Long id, @RequestBody GuestbookDto updatedGuestbook) {
GuestbookEntity updateGuestbook = GuestbookEntity.builder()
.id(id)
.name(updatedGuestbook.getName())
.message(updatedGuestbook.getMessage())
.build();
return service.save(updateGuestbook);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteGuestbook(@PathVariable Long id) {
service.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Service
import com.example.guestbook.domain.entity.GuestbookEntity;
import com.example.guestbook.domain.repository.GuestbookRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@AllArgsConstructor
@Service
public class GuestbookService {
private final GuestbookRepository repository;
public List<GuestbookEntity> findAll() {
return repository.findAll();
}
public GuestbookEntity findById(Long id) {
return repository.findById(id).orElseThrow(() -> new RuntimeException("방명록 항목을 찾을 수 없습니다."));
}
public GuestbookEntity save(GuestbookEntity guestbook) {
return repository.save(guestbook);
}
public void deleteById(Long id) {
repository.deleteById(id);
}
}
Repository
import com.example.guestbook.domain.entity.GuestbookEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GuestbookRepository extends JpaRepository<GuestbookEntity, Long> {
}
Entity
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class GuestbookEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String message;
}
Dto
import lombok.*;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
@Setter
public class GuestbookDto {
private String name = "";
private String message = "";
}
02. 단위 테스트에 대하여
단위 테스트 코드를 작성하기 전에 알아두면 좋을 정보들을 먼저 정리합니다.
1) 단위 테스트란?
단위 테스트는 각 계층별로 분리하여 개별 클래스나 메서드를 테스트합니다.
단위 테스트는 작고 독립적으로 실행될 수 있도록 구성되어야 합니다.
- Controller
- HTTP 요청과 응답을 테스트
- Service
- 비즈니스 로직을 모킹하여 테스트
- Repository
- DB와의 실제 상호작용을 테스트
2) Mock과 Mocking이란?
단위 테스트에서는 실제 환경(ex. DB, 네트워크, 외부 API 등)을 직접 사용하지 않고,
외부 의존성을 대체할 수 있는 가짜 객체(Mock)를 만들어 테스트 대상 코드의 동작을 검증합니다.
이 과정을 Mocking이라고 하며, 이는 테스트의 독립성을 높이고, 테스트가 더 빠르고 신뢰성 있게 동작하도록 만듭니다.
- Mock 객체
- @Mock 등의 애너테이션으로 선언된 객체
- Mocking
- 테스트에서 사용되는 가짜(Mock) 객체를 생성하고 (Given)
이를 통해 외부 의존성과의 상호작용을 흉내 내며 (When)
테스트를 검증하는 행위 (Then)
- 테스트에서 사용되는 가짜(Mock) 객체를 생성하고 (Given)
3) 행위 기반 테스트 (BDD, Behavior-Driven Development)
테스트 코드는 행위 기반 테스트(BDD) 방식으로 given, when, then 구조로 작성하는 것이 직관적입니다.
- Given
- 테스트를 시작하기 전에 주어진 조건이나 상황을 설정하는 부분입니다.
- 객체를 생성하거나 특정 값을 설정하거나 Mock 객체를 설정하는 등의 작업을 합니다.
- When
- 주어진 상태에서 테스트 대상 메서드를 실행하는 부분입니다.
- 실제로 메서드를 호출하여 테스트하려는 행동이 실제로 일어나는 곳이며, 그 결과를 검증할 수 있는 테스트 코드가 작성됩니다.
- Then
- 테스트의 결과가 예상대로 정상적으로 이루어졌는지 확인하는 부분입니다.
- 예상한 값과 일치하는지 확인하는 검증을 수행합니다.
4) 테스트 파일 위치
Maven과 Gradle은 표준 디렉토리 구조(src/main/java 및 src/test/java)를 따르며,
통합 테스트 코드는 기본적으로 src/test/java 아래에 위치해야 빌드 도구에서 자동으로 인식됩니다.
따라서 테스트 파일은 src/test/java 패키지 하위에 작성합니다.
5) JUnit과 AssertJ
- JUnit 5
- 간단하고 기본적인 단위 테스트하는 데 유용한 라이브러리리
- AssertJ
- 더 복잡하고 체이닝 문법을 통한 읽기 쉬운 코드를 제공하며, 특히 다양한 타입의 객체나 조건을 비교해야 할 때 유리합니다.
JUnit 5와 AssertJ는 함께 사용할 수 있습니다
6) 단위 테스트와 통합 테스트
단위 테스트에서는 동작의 관점에서 통합 테스트는 기능의 관점에서 접근하는 것이 좋습니다.
대부분의 비즈니스 로직은 Service와 Repository 계층에서 다루므로, 해당 계층을 중심으로 단위 테스트를 진행하는 것이 좋으며, Controller는 클라이언트-서버 간의 인터페이스나 예외 처리 등을 점검하고 싶을 때 작성하는 것이 좋습니다
이 글에서는 Controller 테스트는 통합 테스트에서 다루고 단위 테스트에서는 비즈니스 로직의 동작을 점검하는데 중점을 두겠습니다.
03. 단위 테스트 코드 작성
Service, Repository를 각각 테스트하는 단위 테스트를 작성해보겠습니다.
1) Service
GuestbookService 서비스 클래스를 메서드 단위로 테스트하는 기본 구조입니다.
import com.example.guestbook.domain.entity.GuestbookEntity;
import com.example.guestbook.domain.repository.GuestbookRepository;
import com.example.guestbook.service.GuestbookService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
public class GuestbookServiceTest {
@Mock
private GuestbookRepository guestbookRepository;
@InjectMocks
private GuestbookService guestbookService;
private GuestbookEntity guestbookEntity;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
guestbookEntity = new GuestbookEntity(1L, "John Doe", "Hello, World!");
}
...
}
- @Mock
- 대상 클래스의 의존성을 실제 구현체 대신 가짜 객체인 모의 객체(Mock Object)로 생성합니다.
- 즉, 테스트 대상 객체의 동작만 검증하기 위해 사용합니다. GustbookRepository는 DB에 접근하는 Bean인데 실제 DB에 접근하지 않더라도 테스트를 할 수 있게 됩니다.
- @InjectMocks
- 테스트 대상 클래스에 의존성 객체를 자동으로 주입합니다. 주입될 객체는 @Mock으로 생성된 모의 객체이거나, 수동으로 생성한 다른 객체일 수 있습니다.
- 즉, GuestbookService 객체를 생성하며면서 클래스 내부에서 의존하는 GuestbookRepository가 자동으로 주입됩니다. 이때, 주입되는 GuestbookRepository는 @Mock으로 생성된 객체입니다. @Mock으로 생성한 Mock 객체가 @InjectMocks로 주입되는 구조입니다.
- GuestbookEntity
- Entity는 Bean이 아닌 JPA의 EntityManager가 생명 주기를 관리하므로 단순히 객체처럼 사용하기만 하면 됩니다.
- @BeforeEach
- 각 테스트 메서드가 실행되기 전에 특정 공통 작업이 필요할 때 사용는 애너테이션입니다.
- 테스트 메서드의 실행 순서에 상관없이 독립적인 초기 상태를 보장하며, 각 테스트 메서드의 실행 직전에 호출됩니다.
- 여기서는 Mockto를 초기화하고 Entity에 객체를 생성하여 할당하는 동작이 있습니다.
- MockitoAnnotations.openMocks()
- Mockito 초기화 : 테스트 클래스에서 @Mock과 @InjectMocks를 사용하려면 Mockito를 초기화해야 합니다.
이제 실제 비즈니스 로직 동작을 체크하는 테스트 코드를 작성하겠습니다.
BDD 방식으로 Given, When, Then 구조로 테스트 코드를 작성합니다.
1-1) Guestbook 추가 서비스 로직
@Test
void testSaveGuestbook() {
// given
when(guestbookRepository.save(any(GuestbookEntity.class))).thenReturn(guestbookEntity);
// when
GuestbookEntity savedGuestbook = guestbookService.save(guestbookEntity);
// then
assertNotNull(savedGuestbook);
assertEquals("John Doe", savedGuestbook.getName());
assertEquals("Hello, World!", savedGuestbook.getMessage());
verify(guestbookRepository, times(1)).save(any(GuestbookEntity.class));
}
- given
- when()
- Mockito의 메서드로, Mock 객체의 특정 메서드가 호출될 때 동작을 정의합니다.
- 괄호 안에 조건으로 지정된 메서드가 호출되었을 때 반환값을 설정하거나 예외를 던지도록 설정할 수 있습니다.
- guestbookRepository.save(any(GuestbookEntity.class))
- Mock 객체인 guestbookRepository의 save 메서드가 호출되었을 때의 조건을 지정합니다.
- any(GuestbookEntity.class)는 어떤 GuestbookEntity 객체가 전달되든 조건을 만족하도록 설정합니다.
- any : 특정 클래스의 객체라면 어떤 값이든 매칭, 매개변수 값이 중요하지 않을 때
- eq : 객체 값이 예상한 값과 완전히 동일해야 할 때
- argThat : 객체의 특정 필드나 조건을 검증할 때
- 커스텀 Matcher : 조건 검증이 복잡하거나 코드 재사용성을 높이고 싶을 때
- 객체 직접 전달 : 생성된 객체와 동일한 객체만 매칭해야 할 때
- thenReturn(guestbookEntity)
- when()의 조건이 충족될 경우 반환할 값을 지정합니다. 여기서는 setUp() 메서드에서 미리 할당해둔 guestbookEntity 객체를 반환하도록 설정했습니다.
- 즉, guestbookRepository.save() 메서드가 호출되면, DB에 실제로 저장되는 것이 아니라 미리 준비된 guestbookEntity 객체를 반환합니다.
- when()
- when
- guestbookService의 save() 메서드를 호출합니다.
- then
- JUnit 5에서 제공하는 assertion 메서드를 사용하여 결과를 검증합니다.
- verify()
- Mockito의 메서드로 호출 검증 기능을 합니다. Mock 객체에서 특정 메서드가 호출되었는지, 호출 횟수가 맞는지, 그리고 올바른 인수가 사용되었는지 등을 확인하기 위해 사용됩니다. 즉, 테스트 코드에서 예상대로 메서드가 동작했는지를 검증할 수 있습니다.
- 이 코드에서는 guestbookRepository의 save() 메서드가 1번, GuestbookEntity 인수로 호출되었는지 확인 합니다.
검증을 실행하는 verify() 메서드는 다음과 같이 다양한 방식으로 사용할 수 있습니다.
// 1) 호출 횟수 검증
//someMethod()가 1번 호출되었는지 확인
verify(mockObject, times(1)).someMethod()
//someMethod()가 호출되지 않았는지 확인
verify(mockObject, never()).someMethod()
//someMethod()가 최소 2번 호출되었는지 확인
verify(mockObject, atLeast(2)).someMethod()
// 2)메서드 인수 검증
// "test"라는 인수로 메서드 호출 여부 확인
verify(mockObject).someMethod("test")
// 어떤 String 인수로 호출되었는지 확인
verify(mockObject).someMethod(any(String.class))
3) 메서드 호출 순서 검증
// firstMethod() 메서드 호출 후 secondMethod()가 호출되었는지 확인
InOrder inOrder = inOrder(mock1, mock2);
inOrder.verify(mock1).firstMethod();
inOrder.verify(mock2).secondMethod();
1-2) Guestbook 조회 Service 로직
@Test
void testGetGuestbookById() {
// given
when(guestbookRepository.findById(1L)).thenReturn(java.util.Optional.of(guestbookEntity));
// when
GuestbookEntity foundGuestbook = guestbookService.findById(1L);
// then
assertNotNull(foundGuestbook);
assertEquals("John Doe", foundGuestbook.getName());
assertEquals("Hello, World!", foundGuestbook.getMessage());
}
- given
- Optional
- JPA의 findById() 메서드는 반환값으로 항상 Optional<T> 타입을 제공하므로, null 처리에 대한 일관성을 유지하기 위해 Optional을 사용합니다.
- Optional
- when
- guestbookService의 findById() 메서드를 호출합니다.
- then
- 결과를 검증합니다.
2) Repository
GuestbookRepository 레포지토리 클래스를 메서드 단위로 테스트하는 기본 구조입니다.
import com.example.guestbook.domain.entity.GuestbookEntity;
import com.example.guestbook.domain.repository.GuestbookRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.junit.jupiter.api.Assertions.*;
@DataJpaTest
public class GuestbookRepositoryTest {
@Autowired
private GuestbookRepository guestbookRepository;
private GuestbookEntity guestbookEntity;
@BeforeEach
void setUp() {
guestbookEntity = new GuestbookEntity(null, "John Doe", "Hello, World!");
}
...
}
- @DataJpaTest
- @DataJpaTest는 JPA와 관련된 Spring Data JPA 구성 요소만 초기화하는 테스트 전용 애너테이션입니다.
- JPA 관련 설정만 로드
- 웹 계층(Controller, Service 등)이나 불필요한 빈은 로드하지 않으므로 테스트가 가볍고 빠릅니다.
- 내장 데이터베이스 사용
- H2 같은 in-memory DB가 의존성으로 포함되어 있다면, 기본적으로 이를 사용하고,
만약 외부 데이터베이스(MySQL, PostgreSQL 등)를 테스트에 사용하고 싶다면 별도의 설정이 필요합니다.
- H2 같은 in-memory DB가 의존성으로 포함되어 있다면, 기본적으로 이를 사용하고,
- 트랜잭션 지원
- 각 테스트 메서드는 트랜잭션 단위로 실행됩니다. 테스트가 끝나면 기본적으로 롤백되므로 테스트 데이터가 외부에 영향을 주지 않습니다.
- Hibernate DDL 생성 검증
- @Entity에 정의된 매핑 정보가 정확한지 확인하며, 잘못된 매핑이 있으면 오류를 발생시킵니다.
- 자동 Repository 주입
- @Repository로 정의된 컴포넌트가 자동으로 주입되며, 이를 통해 데이터베이스 연산을 검증할 수 있습니다.
- JPA 관련 설정만 로드
- @DataJpaTest는 JPA와 관련된 Spring Data JPA 구성 요소만 초기화하는 테스트 전용 애너테이션입니다.
2-1) Guestbook Repository 테스트 로직
@Test
void testSaveGuestbook() {
// when
GuestbookEntity savedEntity = guestbookRepository.save(guestbookEntity);
// then
assertNotNull(savedEntity.getId());
assertEquals("John Doe", savedEntity.getName());
assertEquals("Hello, World!", savedEntity.getMessage());
}
@Test
void testFindById() {
// given
GuestbookEntity savedEntity = guestbookRepository.save(guestbookEntity);
// when
GuestbookEntity foundEntity = guestbookRepository.findById(savedEntity.getId()).orElse(null);
// then
assertNotNull(foundEntity);
assertEquals(savedEntity.getId(), foundEntity.getId());
}
Service 계층 테스트와 크게 다른 부분은 없어서 설명은 생략하겠습니다.
이상으로 단위 테스트를 통해 Service, Repository 계층의 동작을 검증하는 간단한 테스트 코드를 살펴보았습니다.
실제 운영 환경에 적합하기에는 무리가 있지만 개념 정도만 확인하면 좋을것 같습니다.
다음으로는 애플리케이션 전체 기능을 점검하는 통합 테스트 코드를 작성하는 방법에 대해 알아보겠습니다.