안녕하세요!
이번 글에서는 스프링 부트(Spring Boot)로 작성한 블로그 API(글 등록, 전체 조회, 단건 조회, 수정, 삭제)의 동작을 MockMvc를 활용하여 직접 테스트해 보는 방법을 알아보겠습니다.
백엔드 개발에서 테스트 코드는 작성한 로직이 의도대로 동작하는지 검증하고, 향후 코드 수정 시 발생할 수 있는 버그를 방지해 주는 가장 강력한 안전장치입니다.
1. 프로젝트 패키지 및 요구사항 정리
본 예제는 me.gy 패키지 구조를 바탕으로 진행됩니다.
패키지 구조
me.gy.domain: JPA 엔티티 (Article)me.gy.dto: 요청/응답 객체 (AddArticleRequest,UpdateArticleRequest,ArticleResponse)me.gy.repository: JPA 리포지토리 (BlogRepository)me.gy.service: 비즈니스 로직 (BlogService)me.gy.controller: API 컨트롤러 (BlogApiController) 및 테스트 클래스
2. 도메인, DTO, 서비스, 컨트롤러 코드
2.1 Entity (Article.java)
JAVA
package me.gy.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false)
private String content;
@Builder
public Article(String title, String content) {
this.title = title;
this.content = content;
}
// 수정 로직을 추가합니다 (엔티티 내부에서 객체의 상태 변경)
public void update(String title, String content) {
this.title = title;
this.content = content;
}
}
2.2 DTO
(생성 요청 DTO)
JAVA
package me.gy.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import me.gy.domain.Article;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class AddArticleRequest {
private String title;
private String content;
public Article toEntity() {
return Article.builder()
.title(title)
.content(content)
.build();
}
}
(수정 요청 DTO)
JAVA
package me.gy.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class UpdateArticleRequest {
private String title;
private String content;
}
(응답 DTO)
JAVA
package me.gy.dto;
import lombok.Getter;
import me.gy.domain.Article;
@Getter
public class ArticleResponse {
private final String title;
private final String content;
public ArticleResponse(Article article) {
this.title = article.getTitle();
this.content = article.getContent();
}
}
2.3 Repository (BlogRepository.java)
JAVA
package me.gy.repository;
import me.gy.domain.Article;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BlogRepository extends JpaRepository<Article, Long> {
}
2.4 Service (BlogService.java)
JAVA
package me.gy.service;
import lombok.RequiredArgsConstructor;
import me.gy.domain.Article;
import me.gy.dto.AddArticleRequest;
import me.gy.dto.UpdateArticleRequest;
import me.gy.repository.BlogRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@RequiredArgsConstructor
@Service
public class BlogService {
private final BlogRepository blogRepository;
public Article save(AddArticleRequest request) {
return blogRepository.save(request.toEntity());
}
public List<Article> findAll() {
return blogRepository.findAll();
}
public Article findById(Long id) {
return blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found: " + id));
}
@Transactional
public Article update(Long id, UpdateArticleRequest request) {
Article article = blogRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found: " + id));
article.update(request.getTitle(), request.getContent());
return article;
}
public void delete(Long id) {
blogRepository.deleteById(id);
}
}
2.5 Controller (BlogApiController.java)
JAVA
package me.gy.controller;
import lombok.RequiredArgsConstructor;
import me.gy.domain.Article;
import me.gy.dto.AddArticleRequest;
import me.gy.dto.ArticleResponse;
import me.gy.dto.UpdateArticleRequest;
import me.gy.service.BlogService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RequiredArgsConstructor
@RestController
public class BlogApiController {
private final BlogService blogService;
// 글 등록 API
@PostMapping("/api/articles")
public ResponseEntity<Article> addArticle(@RequestBody AddArticleRequest request) {
Article savedArticle = blogService.save(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(savedArticle);
}
// 글 전체 조회 API
@GetMapping("/api/articles")
public ResponseEntity<List<ArticleResponse>> findAllArticles() {
List<ArticleResponse> articles = blogService.findAll().stream()
.map(ArticleResponse::new)
.toList();
return ResponseEntity.ok()
.body(articles);
}
// 글 단건 조회 API
@GetMapping("/api/articles/{id}")
public ResponseEntity<ArticleResponse> findArticle(@PathVariable Long id) {
Article article = blogService.findById(id);
return ResponseEntity.ok()
.body(new ArticleResponse(article));
}
// 글 수정 API
@PutMapping("/api/articles/{id}")
public ResponseEntity<Article> updateArticle(@PathVariable Long id, @RequestBody UpdateArticleRequest request) {
Article updatedArticle = blogService.update(id, request);
return ResponseEntity.ok()
.body(updatedArticle);
}
// 글 삭제 API
@DeleteMapping("/api/articles/{id}")
public ResponseEntity<Void> deleteArticle(@PathVariable Long id) {
blogService.delete(id);
return ResponseEntity.ok()
.build();
}
}
3. 테스트 코드 작성하기
이제 완성된 API 엔드포인트들을 검증하는 컨트롤러 테스트 코드를 작성해 보겠습니다.
JAVA
package me.gy.controller;
import me.gy.domain.Article;
import me.gy.dto.AddArticleRequest;
import me.gy.dto.UpdateArticleRequest;
import me.gy.repository.BlogRepository;
import org.junit.jupiter.api.BeforeEach;
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.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.web.context.WebApplicationContext;
import tools.jackson.databind.ObjectMapper;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class BlogApiControllerTest {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected WebApplicationContext context;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
private BlogRepository blogRepository;
@BeforeEach
public void mockMvcSetUp() {
blogRepository.deleteAll();
}
@DisplayName("addArticle: 블로그 글 추가에 성공한다")
@Test
public void addArticle() throws Exception {
// given
String url = "/api/articles";
String title = "title";
String content = "content";
AddArticleRequest req = new AddArticleRequest(title, content);
final String requestBody = objectMapper.writeValueAsString(req);
// when
mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody)
);
// then
List<Article> articles = blogRepository.findAll();
assertThat(articles.size()).isEqualTo(1);
assertThat(articles.get(0).getTitle()).isEqualTo(title);
assertThat(articles.get(0).getContent()).isEqualTo(content);
}
@DisplayName("findAllArticles: 블로그 글 목록 조회에 성공한다")
@Test
public void findAllArticles() throws Exception {
// given
String url = "/api/articles";
String title = "title";
String content = "content";
blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
// when
final ResultActions resultActions = mockMvc.perform(get(url)
.accept(MediaType.APPLICATION_JSON)
);
// then
resultActions.andExpect(status().isOk())
.andExpect(jsonPath("$[0].content").value(content))
.andExpect(jsonPath("$[0].title").value(title));
}
@DisplayName("findArticle: 블로그 글 단건 조회에 성공한다")
@Test
public void findArticle() throws Exception {
// given
String url = "/api/articles/{id}";
String title = "title";
String content = "content";
Article savedArticle = blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
// when
final ResultActions resultActions = mockMvc.perform(get(url, savedArticle.getId()));
// then
resultActions.andExpect(status().isOk())
.andExpect(jsonPath("$.content").value(content))
.andExpect(jsonPath("$.title").value(title));
}
@DisplayName("updateArticle: 블로그 글 수정에 성공한다")
@Test
public void updateArticle() throws Exception {
// given
String url = "/api/articles/{id}";
String title = "title";
String content = "content";
Article savedArticle = blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
String newTitle = "new title";
String newContent = "new content";
UpdateArticleRequest request = new UpdateArticleRequest(newTitle, newContent);
// when
ResultActions result = mockMvc.perform(put(url, savedArticle.getId())
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsString(request))
);
// then
result.andExpect(status().isOk());
Article article = blogRepository.findById(savedArticle.getId()).get();
assertThat(article.getTitle()).isEqualTo(newTitle);
assertThat(article.getContent()).isEqualTo(newContent);
}
@DisplayName("deleteArticle: 블로그 글 삭제에 성공한다")
@Test
public void deleteArticle() throws Exception {
// given
String url = "/api/articles/{id}";
String title = "title";
String content = "content";
Article savedArticle = blogRepository.save(Article.builder()
.title(title)
.content(content)
.build());
// when
mockMvc.perform(delete(url, savedArticle.getId()))
.andExpect(status().isOk());
// then
List<Article> articles = blogRepository.findAll();
assertThat(articles).isEmpty();
}
}
테스트 코드가 어떻게 작동하는지 주요 어노테이션과 메서드별로 나누어 정리했습니다.
4.1 핵심 어노테이션 & 객체 역할
@SpringBootTest- 테스트 실행 시 스프링 컨테이너 전체를 구동시켜 모든 빈(Bean)을 로딩합니다. 실제 운영 환경과 가장 유사한 통합 테스트 환경을 만듭니다.
@AutoConfigureMockMvc- 서버를 직접 실행(Tomcat 등을 구동)하지 않고도 HTTP 요청과 응답을 시뮬레이션할 수 있는
MockMvc객체를 자동으로 생성해 줍니다.
- 서버를 직접 실행(Tomcat 등을 구동)하지 않고도 HTTP 요청과 응답을 시뮬레이션할 수 있는
ObjectMapper- 자바 객체(DTO)를 HTTP 요청 본문(Body)에 넣기 위해 JSON 문자열로 직렬화(Serialization) 하거나, 반대 과정으로 변환할 때 사용합니다. (
objectMapper.writeValueAsString(request))
- 자바 객체(DTO)를 HTTP 요청 본문(Body)에 넣기 위해 JSON 문자열로 직렬화(Serialization) 하거나, 반대 과정으로 변환할 때 사용합니다. (
@BeforeEach메서드 내blogRepository.deleteAll()- 각
@Test메서드가 실행되기 전에 기존 데이터베이스의 데이터를 깨끗이 지웁니다. - 테스트 간의 독립성(Isolated Test)을 유지하여 이전 테스트의 데이터 잔재가 다음 테스트에 영향을 미치지 않도록 방지합니다.
- 각
4.2 테스트 메서드 흐름 (Given-When-Then 패턴)
테스트 코드는 가독성을 높이기 위해 보통 Given - When - Then 3단계 구조로 구분하여 작성합니다.
- Given (준비): 테스트를 수행하기 위해 필요한 사전 데이터와 환경(URL, DTO, DB 저장)을 설정합니다.
- When (실행):
mockMvc.perform()을 통해 실제 HTTP 요청(POST, GET, PUT, DELETE)을 보냅니다. - Then (검증): 요청의 결과로 나온 HTTP 상태 코드(
status().isOk())나 JSON 데이터 값(jsonPath), 혹은 DB의 최종 상태(assertThat)를 검증합니다.
jsonPath("$[0].title"): 배열 형태의 JSON 응답에서 첫 번째 요소의title필드를 지정합니다.jsonPath("$.title"): 단일 객체 형태의 JSON 응답에서title필드를 지정합니다.assertThat(articles).isEmpty(): 삭제 처리 후 DB 조회를 했을 때 데이터가 존재하지 않는지(비어 있는지) 검증합니다.
5. 마치며
이번 글에서는 스프링 부트에서 MockMvc를 활용하여 CRUD 전체 기능에 대한 컨트롤러 테스트 코드를 작성해 보았습니다.
- POST (
/api/articles) ➔ HTTP 요청 ➔ DB 저장값 검증 - GET (
/api/articles) ➔ HTTP 요청 ➔ JSON 응답값 검증 - PUT (
/api/articles/{id}) ➔ HTTP 요청 ➔ DB 내 변경된 데이터 검증 - DELETE (
/api/articles/{id}) ➔ HTTP 요청 ➔ DB의 비어있음(isEmpty()) 검증
