테스트는 코드를 믿을 수 있게 해 주는 안전망입니다
지금까지 게시글을 등록하고, 조회하고, 잘못된 입력을 검증하고, 예외를 JSON으로 응답하는 코드를 만들었습니다. 그런데 기능을 하나 고칠 때마다 Postman이나 브라우저로 모든 API를 다시 눌러 보는 일은 금방 지칩니다.
테스트 코드는 이 반복 확인을 자동으로 해 주는 코드입니다. 비유하면 등산할 때 매번 손으로 밧줄을 잡아당겨 확인하는 대신, 미리 튼튼한 안전망을 설치해 두는 것과 비슷합니다. 코드를 수정한 뒤 테스트를 실행하면 "아직 정상 동작한다" 또는 "여기서 깨졌다"를 빠르게 알 수 있습니다.
Spring Boot 프로젝트를 만들 때 spring-boot-starter-test가 들어 있었다면 보통 JUnit, AssertJ, Mockito, Spring Test 관련 도구를 함께 사용할 수 있습니다.
// build.gradle
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}JUnit: 테스트 메서드를 실행해 주는 도구입니다.
@Test를 붙인 메서드를 찾아 실행합니다.AssertJ: 결과가 기대와 같은지 읽기 쉬운 문장으로 검증하는 도구입니다.
assertThat(result).isEqualTo(...)처럼 씁니다.Mockito: 아직 진짜 객체를 쓰기 어렵거나 외부 의존성을 끊고 싶을 때 가짜 객체를 만들어 주는 도구입니다.
AI 생성 이미지
Service 테스트: 작은 단위부터 확인하기
먼저 스프링 전체를 실행하지 않고 PostService의 한 메서드만 확인해 보겠습니다. 이런 테스트를 보통 단위 테스트라고 부릅니다. 작은 부품 하나를 책상 위에 올려놓고 제대로 움직이는지 보는 방식입니다.
예를 들어 게시글을 조회할 때 저장소가 게시글을 돌려주면 서비스가 PostResponse로 잘 바꾸는지 확인해 보겠습니다.
package com.example.board.service;
import com.example.board.domain.Post;
import com.example.board.dto.PostResponse;
import com.example.board.exception.PostNotFoundException;
import com.example.board.repository.PostRepository;
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.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
@ExtendWith(MockitoExtension.class)
class PostServiceTest {
@Mock
PostRepository postRepository;
@InjectMocks
PostService postService;
@Test
void 게시글을_단건_조회한다() {
// given: 테스트에 필요한 상황을 준비합니다.
Post post = new Post("첫 글", "테스트 내용입니다.");
given(postRepository.findById(1L)).willReturn(Optional.of(post));
// when: 실제로 테스트할 메서드를 실행합니다.
PostResponse response = postService.getPost(1L);
// then: 결과가 기대와 같은지 확인합니다.
assertThat(response.title()).isEqualTo("첫 글");
assertThat(response.content()).isEqualTo("테스트 내용입니다.");
}
@Test
void 없는_게시글을_조회하면_예외가_발생한다() {
given(postRepository.findById(999L)).willReturn(Optional.empty());
assertThatThrownBy(() -> postService.getPost(999L))
.isInstanceOf(PostNotFoundException.class)
.hasMessageContaining("게시글");
}
}테스트는 보통 세 단계로 나누면 읽기 쉽습니다.
given: 이런 상황에서
when : 이 기능을 실행하면
then : 이런 결과가 나와야 한다@Mock은 가짜 저장소를 만듭니다. 실제 데이터베이스에 접근하지 않습니다. @InjectMocks는 그 가짜 저장소를 PostService에 넣어 줍니다. 그래서 이 테스트는 빠릅니다. 대신 스프링 설정, JPA 매핑, 실제 DB 연결까지 확인하지는 않습니다.
팁: 테스트 메서드 이름은 길어도 괜찮습니다.
test1()보다없는_게시글을_조회하면_예외가_발생한다()가 실패했을 때 훨씬 이해하기 쉽습니다.
@SpringBootTest와 슬라이스 테스트
스프링 테스트는 크게 두 방향으로 나눌 수 있습니다.
첫째, @SpringBootTest는 애플리케이션을 거의 통째로 띄워 테스트합니다. Spring Boot 공식 문서에서도 @SpringBootTest는 테스트에서 Spring Boot 기능이 필요할 때 ApplicationContext를 만들기 위해 사용하는 애너테이션이라고 설명합니다.1
@SpringBootTest
class BoardApplicationTests {
@Test
void contextLoads() {
}
}이 테스트는 별 내용이 없어 보이지만 의미가 있습니다. 스프링 컨테이너가 정상적으로 뜨는지 확인합니다. Bean 생성 오류, 의존성 주입 오류, 설정 오류가 있으면 여기서 실패합니다.
둘째, 슬라이스 테스트는 애플리케이션 전체가 아니라 한 조각만 잘라서 테스트합니다. 케이크 전체를 먹어 보지 않고 크림층만 맛보는 것과 같습니다.
대표적인 슬라이스 테스트는 다음과 같습니다.
| 테스트 | 확인하는 범위 | 사용할 때 |
|---|---|---|
@WebMvcTest | Controller와 MVC 관련 부분 | 요청 URL, 상태 코드, JSON 응답, 검증 오류 확인 |
@DataJpaTest | JPA Repository와 Entity 매핑 | 저장, 조회, 쿼리, 연관관계 확인 |
@SpringBootTest | 애플리케이션 전체에 가까운 범위 | 여러 계층이 함께 동작하는지 확인 |
선택 기준은 단순합니다.
서비스 메서드의 계산이나 예외 흐름만 빠르게 보고 싶다 → JUnit + Mockito 단위 테스트
컨트롤러의 URL, 요청 JSON, 응답 JSON만 보고 싶다 →
@WebMvcTestRepository와 Entity가 DB에 잘 저장되는지 보고 싶다 →
@DataJpaTestController, Service, Repository, 설정까지 묶어서 보고 싶다 →
@SpringBootTest
처음에는 모든 것을 @SpringBootTest로 테스트하고 싶을 수 있습니다. 하지만 전체를 띄우는 테스트는 느립니다. 테스트가 많아질수록 "작게 확인할 수 있는 것은 작게 확인한다"는 원칙이 중요해집니다.
MockMvc로 Controller 테스트하기
Controller는 HTTP 요청의 입구입니다. 여기서는 실제 서버를 8080 포트로 띄우지 않고도 요청과 응답을 확인할 수 있습니다. 이때 쓰는 도구가 MockMvc입니다. MockMvc는 실제 서버 대신 가짜 요청과 가짜 응답 객체로 Spring MVC 요청 처리를 테스트하게 해 줍니다.2
먼저 게시글 목록 조회 API를 테스트해 보겠습니다.
package com.example.board.controller;
import com.example.board.dto.PostResponse;
import com.example.board.service.PostService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PostApiController.class)
class PostApiControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
PostService postService;
@Test
void 게시글_목록을_조회한다() throws Exception {
given(postService.getPosts()).willReturn(List.of(
new PostResponse(1L, "첫 글", "내용입니다.")
));
mockMvc.perform(get("/api/posts"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(1L))
.andExpect(jsonPath("$[0].title").value("첫 글"))
.andExpect(jsonPath("$[0].content").value("내용입니다."));
}
}mockMvc.perform(get("/api/posts"))는 GET /api/posts 요청을 보낸다는 뜻입니다. 이어지는 andExpect는 응답이 기대와 같은지 확인합니다.
status().isOk()는 HTTP 상태 코드가 200인지 확인합니다.jsonPath("$[0].title")은 JSON 배열의 첫 번째 객체에서title값을 꺼냅니다.
게시글 등록처럼 JSON 본문을 보내야 하는 API는 ObjectMapper를 함께 사용하면 편합니다. ObjectMapper는 자바 객체를 JSON 문자열로 바꾸거나, JSON 문자열을 자바 객체로 바꿔 주는 도구입니다.
package com.example.board.controller;
import com.example.board.dto.PostCreateRequest;
import com.example.board.dto.PostResponse;
import com.example.board.service.PostService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PostApiController.class)
class PostApiControllerCreateTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
PostService postService;
@Test
void 게시글을_등록한다() throws Exception {
PostCreateRequest request = new PostCreateRequest("새 글", "새 내용입니다.");
given(postService.createPost(any(PostCreateRequest.class)))
.willReturn(new PostResponse(1L, "새 글", "새 내용입니다."));
mockMvc.perform(post("/api/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.title").value("새 글"));
}
}사용 중인 코드의 메서드 이름이 getPosts()나 createPost()가 아니라면 프로젝트에 맞게 바꾸면 됩니다. 테스트의 핵심은 "요청을 보냈을 때 원하는 상태 코드와 JSON이 나오는가"입니다.
흔한 오류:
Content type 'text/plain' not supported가 나오면contentType(MediaType.APPLICATION_JSON)을 빠뜨렸는지 확인하세요. JSON 요청이라고 서버에 알려 주는 표시입니다.
테스트 데이터베이스 분리하기
테스트가 실제 개발 DB나 운영 DB를 건드리면 위험합니다. 테스트는 데이터를 만들고 지우는 일을 자주 합니다. 그래서 테스트용 데이터베이스를 따로 둬야 합니다.
가장 쉬운 방법은 src/test/resources/application-test.properties 파일을 만드는 것입니다.
spring.datasource.url=jdbc:h2:mem:board-test;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false그리고 테스트 클래스에 @ActiveProfiles("test")를 붙입니다.
package com.example.board.repository;
import com.example.board.domain.Post;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@ActiveProfiles("test")
class PostRepositoryTest {
@Autowired
PostRepository postRepository;
@Test
void 게시글을_저장하고_조회한다() {
Post saved = postRepository.save(new Post("저장 테스트", "본문"));
Optional<Post> found = postRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("저장 테스트");
}
}@DataJpaTest는 JPA와 관련된 부분만 띄웁니다. Repository, Entity 매핑, 실제 SQL 실행 여부를 확인하기 좋습니다. 또한 보통 각 테스트가 끝나면 변경 내용을 롤백하므로 테스트끼리 영향을 덜 주게 됩니다.
그래도 다음 원칙은 지키는 것이 좋습니다.
테스트 실행 순서에 의존하지 않습니다.
한 테스트에서 만든 데이터를 다른 테스트가 기대하지 않게 합니다.
필요한 데이터는 각 테스트 안에서 직접 만듭니다.
전체 통합 테스트에서 데이터가 남는다면
@BeforeEach에서deleteAll()로 정리합니다.
@BeforeEach
void setUp() {
postRepository.deleteAll();
}특히 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)처럼 실제 서버를 띄우는 테스트에서는 테스트 메서드의 트랜잭션 롤백이 서버 쪽 작업까지 항상 같은 방식으로 적용된다고 기대하면 안 됩니다. 이런 경우에는 테스트 전후로 데이터를 명시적으로 정리하는 편이 안전합니다.
이번 장에서 기억할 것
테스트는 처음에는 느리게 느껴질 수 있습니다. 하지만 기능이 늘어날수록 테스트는 개발 속도를 지켜 줍니다.
작은 서비스 로직은 JUnit과 AssertJ로 빠르게 확인합니다. Controller는 MockMvc로 요청과 응답을 확인합니다. Repository는 테스트 DB와 @DataJpaTest로 확인합니다. 여러 계층이 함께 동작하는지는 @SpringBootTest로 확인합니다.
좋은 테스트는 코드를 고칠 용기를 줍니다. 테스트가 통과하면 지금까지 만든 게시판 API가 여전히 약속대로 동작한다는 신호를 얻을 수 있습니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.