웹 개발/Backend

[Java / Spring] Spring Boot 테스트 작성하기

devsean 2025. 7. 29. 12:43

테스트 코드

소프트웨어를 테스트하는 것은 품질 관리에 있어 중요하다. 프로젝트 수행 시, E2E 테스트 케이스를 문서로 작성하여 직접 수행해보고 통과 여부를 작성한다. 테스트를 수행하기에 앞서(혹은 수행하는 대신), 개발한 소프트웨어를 코드 레벨에서 테스트할 수 있다면 보다 효과적으로 개발할 수 있다. 장애가 발생했을 때 원인을 빠르게 파악할 수 있고, 리팩토링 시 코드가 잘 작동함을 보장할 수도 있다. 이번 포스팅에서는 간단한 Spring Boot 어플리케이션을 작성하고, 테스트 코드를 작성해보면서 방법을 익힌다.

 

프로젝트 셋업

다음과 같이 셋팅한다. start.spring.io의 initalizer를 활용하였다.

 

build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.5.4'
	id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	runtimeOnly 'com.h2database:h2'

	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'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

application.yml

내장 경량 DB인 h2 database를 활용한다.

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

 

어플리케이션 구조 및 소스코드

간단한 CRUD 어플리케이션이다.

 

구조

 

 

Controller

import com.example.demo.domain.entity.GuestbookEntity;
import com.example.demo.dto.GuestbookDto;
import com.example.demo.service.GuestbookService;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RequiredArgsConstructor
@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.demo.domain.entity.GuestbookEntity;
import com.example.demo.domain.repository.GuestbookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@RequiredArgsConstructor
@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 ResponseStatusException(HttpStatus.NOT_FOUND, "방명록 항목을 찾을 수 없습니다."));
    }

    public GuestbookEntity save(GuestbookEntity guestbook) {
        return repository.save(guestbook);
    }

    public void deleteById(Long id) {
        repository.deleteById(id);
    }

}

 

Repository

import com.example.demo.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 = "";
}

 

테스트 파일의 위치

Maven과 Gradle은 표준 디렉토리 구조를 따르며, 테스트 코드는 기본적으로 src/test/java 아래에 위치해야 빌드 도구에서 자동으로 인식한다. 따라서 테스트 파일은 해당 경로 하위에 작성한다.

 

테스트 관련 라이브러리

JUnit 5와 AssertJ, Mockito를 주로 활용한다. JUnit 5는 간단하고 기본적인 단위 테스트를 수행하는 데 유용한 라이브러리이며, AssertJ는 더 복잡한 테스트를 작성하는 데 활용된다. 체이닝 문법을 통해 읽기 쉬운 코드를 제공하며, 특히 다양한 타입의 객체나 조건을 비교해야 할 때 유리하다. Mockito는 단위 테스트를 수행할 때 필요한 가짜 객체(Mock 객체)를 주입 및 활용하기 위해 사용된다. 참고로 해당 라이브러리들은 spring-boot-starter-test를 build.gradle에 설정하면 자동으로 제공된다.

 

행위 기반 테스트(Given-When-Then)

테스트 코드는 행위 기반 테스트 방식으로, Given-When-Then 구조로 작성하는 것이 직관적이다.

 

Given

  • 테스트를 시작하기 전에 주어진 조건이나 상황을 설정한다.
  • 객체 생성, 특정 값 설정, Mock 객체 설정 등의 작업을 진행한다.

When

  • 주어진 상태에서 테스트 대상 메서드를 실행한다.
  • 실제로 메서드를 호출하여 테스트하려는 행동이 일어나는 부분이다.

Then

  • 테스트의 결과가 예상대로 이루어졌는지 확인하는 부분이다.
  • 예상한 값과 일치하는지 확인하는 검증을 수행한다.

 

단위 테스트

단위 테스트는 각 계층별로 분리하여 개별 클래스나 메서드를 테스트한다. “동작”의 관점에서 접근하며, MVC 계층 중 비즈니스 로직을 다루는 Service와 Repository를 테스트할 때 적절하다. 필요하다면 Controller 계층만 떼어서 클라이언트-서버 간의 인터페이스나 예외 처리 등을 점검할 때 활용할 수도 있다(그러나 통합 테스트를 적용하는 것이 자연스러워 보인다).

 

한편 단위 테스트에서는 실제 환경을 직접 사용하지 않고, 외부 의존성을 대체할 수 있는 가짜 객체(Mock)를 만들어 동작을 검증하게 된다. Service에 존재하는 순수한 비즈니스 로직이나, Repository에 존재하는 DB I/O 로직은 실제 작동 시에는 엮여있으나, 각각 분리되어 테스트 되어야 한다. 그러므로 테스트 시에는 필요한 의존성을 Mocking하여 사용한다.

 

Service 계층의 단위 테스트 코드

import com.example.demo.domain.entity.GuestbookEntity;
import com.example.demo.domain.repository.GuestbookRepository;
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.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class GuestbookServiceTest {

    // Repository를 Mock 객체로 생성
    @Mock
    private GuestbookRepository guestbookRepository;

    // 테스트 대상 객체, 해당 객체가 필요한 의존성을 Mock 객체를 활용하여 주입해준다.
    @InjectMocks
    private GuestbookService guestbookService;

    private GuestbookEntity guestbookEntity;

    // 테스트 메서드 실행 직전에 필요한 공통 작업
    // Mockito 초기화 및 Entity 객체 생성하여 할당
    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
        guestbookEntity = new GuestbookEntity(1L, "John Doe", "Hello, World!");
    }

    @Test
    void testSaveGuestbook(){

        // given
        // when() : Mock 객체(guestbookRepository)가 특정 작업을 수행했을 때 예상되는 결과를 정의한다.
        when(guestbookRepository.save(any(GuestbookEntity.class))).thenReturn(guestbookEntity);

        // when
        GuestbookEntity savedGuestbook = guestbookService.save(guestbookEntity);

        // then
        // assert : JUnit 5의 단언문
        assertNotNull(savedGuestbook);
        assertEquals("John Doe", savedGuestbook.getName());
        assertEquals("Hello, World!", savedGuestbook.getMessage());

        // verify : Mock 객체의 동작을 검증한다. 여기서는 save 동작이 1회만 이루어졌는지를 검증한다.
        verify(guestbookRepository, times(1)).save(any(GuestbookEntity.class));

    }

    @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());

    }

}

 

Repository 계층의 단위 테스트 코드

import com.example.demo.domain.entity.GuestbookEntity;
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
* Spring에서 제공하는 JPA 단위 테스트 Annotation
* H2 DB를 활용하여 DB I/O를 테스트한다. 
* 각 Test가 끝나면 Rollback 된다.
* */
@DataJpaTest
class GuestbookRepositoryTest {

    @Autowired
    private GuestbookRepository guestbookRepository;

    private GuestbookEntity guestbookEntity;

    @BeforeEach
    void setUp() {
        guestbookEntity = new GuestbookEntity(null, "John Doe", "Hello, World!");
    }

    @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());

    }

}

 

 

통합 테스트

“기능”의 관점에서 접근한다. 하나의 기능이 완결되게 동작하는지를 테스트한다. 보통 서버의 입장에서는 기능을 수행하기 위해 화면으로부터 Endpoint에 요청이 들어오므로, 자연스럽게 Controller 계층을 테스트한다는 관점으로 활용할 수 있다.

전체 애플리케이션 컨텍스트(ApplicationContext)를 로드하여 실제 API 호출이 예상대로 동작하는 지 확인한다.

 

@SpringBootTest를 사용한다. 실제 환경과 유사하지만, 애플리케이션이 클수록 테스트가 오래 걸린다.

 

설정

실제 API 호출을 테스트하므로, 애플리케이션 기동 및 요청 수행 관련하여 설정을 잡아주어야 한다. 다음과 같이 설정할 수 있다.

// 애플리케이션을 실제로 실행하고 무작위 포트에서 통합 테스트를 수행한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GuestbookIntegrationTest {

    // 내장 웹 서버를 실행하는 경우, 자동으로 할당된 포트를 얻고 싶을 때 사용한다.
    @LocalServerPort
    private int port;

    // REST API를 호출하여 실제 HTTP 요청/응답을 테스트할 수 있는 유틸리티 클래스
    @Autowired
    private TestRestTemplate restTemplate;

    // 호출할 요청의 Base URL
    private String getBaseUrl(){
        return "<http://localhost>:" + port + "/api/guestbook";
    }
    
	  //  이후 필요한 테스트 작성

}

 

 

통합 테스트 코드 전문

import com.example.demo.domain.entity.GuestbookEntity;
import com.example.demo.dto.GuestbookDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

// 애플리케이션을 실제로 실행하고 무작위 포트에서 통합 테스트를 수행한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GuestbookIntegrationTest {

    // 내장 웹 서버를 실행하는 경우, 자동으로 할당된 포트를 얻고 싶을 때 사용한다.
    @LocalServerPort
    private int port;

    // REST API를 호출하여 실제 HTTP 요청/응답을 테스트할 수 있는 유틸리티 클래스
    @Autowired
    private TestRestTemplate restTemplate;

    // 호출할 요청의 Base URL
    private String getBaseUrl(){
        return "<http://localhost>:" + port + "/api/guestbook";
    }

    @DisplayName("방명록을 생성하고 확인한다.")
    @Test
    void testCreateGuestbook() {

        /* POST 요청을 통해 방명록을 생성하고, GET 요청을 통해 잘 생성되었는지 확인한다. */

        // given
        var newEntry = GuestbookDto.builder()
                .name("홍길동")
                .message("안녕하세요!")
                .build();

        // when : POST 요청
        ResponseEntity<GuestbookEntity> createResponse = restTemplate.postForEntity(getBaseUrl(), newEntry, GuestbookEntity.class);

        // then : POST 응답 확인
        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        GuestbookEntity createdEntry = createResponse.getBody();
        assertThat(createdEntry).isNotNull();
        assertThat(createdEntry.getId()).isNotNull();
        assertThat(createdEntry.getName()).isEqualTo("홍길동");
        assertThat(createdEntry.getMessage()).isEqualTo("안녕하세요!");

        // when : GET 요청
        ResponseEntity<GuestbookEntity> getResponse = restTemplate.getForEntity(getBaseUrl() + "/" + createdEntry.getId(), GuestbookEntity.class);

        // then : GET 요청 확인
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        GuestbookEntity retrievedEntry = getResponse.getBody();
        assertThat(retrievedEntry).isNotNull();
        assertThat(retrievedEntry.getId()).isEqualTo(createdEntry.getId());
        assertThat(retrievedEntry.getName()).isEqualTo("홍길동");
        assertThat(retrievedEntry.getMessage()).isEqualTo("안녕하세요!");
    }

    @DisplayName("방명록을 수정한 뒤 확인한다.")
    @Test
    void testUpdateGuestBook(){

        /* 생성된 방명록을 PUT 요청을 통해 수정하며, GET 요청을 통해 잘 수정되었는지 확인한다. */

        // given : 방명록 생성
        var newEntry = GuestbookDto.builder()
                .name("홍길동")
                .message("안녕하세요!")
                .build();

        ResponseEntity<GuestbookEntity> createResponse = restTemplate.postForEntity(getBaseUrl(), newEntry, GuestbookEntity.class);
        GuestbookEntity createdEntry = createResponse.getBody();
        assertThat(createdEntry).isNotNull();

        // when : PUT 요청을 통해 방명록 수정
        GuestbookDto updatedEntry = GuestbookDto.builder()
                .name("홍길동")
                .message("반갑습니다")
                .build();

        restTemplate.put(getBaseUrl() + "/" + createdEntry.getId(), updatedEntry);

        // then : GET 요청을 통해 결과 확인
        ResponseEntity<GuestbookEntity> getResponse = restTemplate.getForEntity(getBaseUrl() + "/" + createdEntry.getId(), GuestbookEntity.class);
        GuestbookEntity updatedEntity = getResponse.getBody();

        assertThat(updatedEntity).isNotNull();
        assertThat(updatedEntity.getId()).isEqualTo(createdEntry.getId());
        assertThat(updatedEntity.getMessage()).isEqualTo("반갑습니다");

    }

    @DisplayName("방명록을 생성한 뒤 확인한다.")
    @Test
    void testDeleteGuestbook(){

        /* 생성된 방명록을 DELETE 요청을 통해 삭제하며, GET 요청을 통해 NOT FOUND인지 확인한다. */

        // given : 방명록 생성
        var newEntry = GuestbookDto.builder()
                .name("홍길동")
                .message("안녕하세요!")
                .build();

        ResponseEntity<GuestbookEntity> createResponse = restTemplate.postForEntity(getBaseUrl(), newEntry, GuestbookEntity.class);

        GuestbookEntity createdEntry = createResponse.getBody();
        assertThat(createdEntry).isNotNull();

        // when : 방명록 삭제
        restTemplate.delete(getBaseUrl() + "/" + createdEntry.getId());

        // then : 삭제된 항목 조회 시 404 응답 확인
        ResponseEntity<GuestbookEntity> getResponse = restTemplate.getForEntity(getBaseUrl() + "/" + createdEntry.getId(), GuestbookEntity.class);
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);

    }

    @DisplayName("생성된 모든 방명록을 확인한다.")
    @Test
    void testGetAllGuestbook() {

        // given : 방명록 여러 항목 생성
        restTemplate.postForEntity(getBaseUrl(), GuestbookDto.builder().name("홍길동").message("안녕하세요!").build(), GuestbookEntity.class);
        restTemplate.postForEntity(getBaseUrl(), GuestbookDto.builder().name("이길동").message("반갑습니다!").build(), GuestbookEntity.class);

        // when : 모든 항목 조회
        ResponseEntity<List> getResponse = restTemplate.getForEntity(getBaseUrl(), List.class);

        // then
        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        List<?> entries = getResponse.getBody();
        assertThat(entries).isNotNull();
        assertThat(entries.size()).isGreaterThanOrEqualTo(2);
    }

}

 

참고 자료

[Springboot] 테스트 코드 작성 (1) - 단위 테스트

 

[Springboot] 테스트 코드 작성 (1) - 단위 테스트

Updated - 2024. 12. 08   테스트 코드 작성 (1) - 단위 테스트테스트 코드 작성 (2) - 통합 테스트  이 글에서는 REST-API를 단위 테스트 코드를 작성하는 방법에 대해 알아보겠습니다.   01. REST-API 방

victorydntmd.tistory.com

 

[Springboot] 테스트 코드 작성 (2) - 통합 테스트 (@SpringBootTest)

 

[Springboot] 테스트 코드 작성 (2) - 통합 테스트 (@SpringBootTest)

Updated - 2024. 12. 08  테스트 코드 작성 (1) - 단위 테스트테스트 코드 작성 (2) - 통합 테스트 지난 글에서는 테스트 코드에 대한 전반적인 내용과 단위 테스트 코드를 작성하는 방법에 대해 알아보

victorydntmd.tistory.com