[SB] 27. 통합 테스트

최재원's avatar
Jun 10, 2025
[SB] 27. 통합 테스트

1. 로컬 컴퓨터에서 잘 돌아가는 것이 운영 서버에서 잘 돌아가는지 확인하기 위함

  1. test 서버에서 자동으로 test가 되고 문제 없으면 실제 서버에 배포한다
    1. 문제가 생기면 빌드가 실패하여 jar 파일(자바 실행 파일)이 생성되지 않는다
    2. 만약 실행할 서버가 5개 라면 test서버가 없을 때 5개의 서버 각각 테스트를 해야 한다.
  1. 빌드만 하면 자동 test가 실행된다
    1. 빌드 프로그램이 빌드 할 때 자동으로 test 코드를 실행 해준다
  1. 로컬에서 잘 돌아가면 test 코드를 작성함
    1. 내가 하나하나 기능을 다 실행할 필요가 없다 = postman으로 하나하나 실행 해보는 것은 미친 짓이다

2. 코드 수정 후 의존 관계 문제를 확인하기 위함

의존 관계 문제
  • 여러 의존 관계를 맺고 있는 어떤 객체를 수정하면 테스트를 하지 않고 그냥 빌드 할 때 연관되어있는 다른 코드들이 다 터진다
  • 의존 관계가 어떻게 있는지 모르는 코드를 수정할 때는 수정 후 테스트를 꼭 실행시켜봐야 한다
  • 테스트를 실행 시켜야 이 코드와 연결되어 있는 다른 코드에서 발생하는 에러를 찾을 수 있다

이론

1. 개발 과정

notion image
  • 각자의 컴퓨터로 개발을 하고 github 에 통합 시킨다
  • 계속되는 수정 보완이 발생하기 때문에 지속적으로 개발을 하고 통합을 시킨다

✅ CI (지속적 통합)란?

  • Continuous Integration: 지속적인 코드 통합 및 자동 테스트
  • 개발자들이 코드를 GitHub 와 같은 원격 저장소에 푸시할 때마다
    • 자동으로 빌드 및 테스트가 수행되어 안정성을 확보
  • 보통 v1.0, v1.1 처럼 버전 태깅을 통해 변경 이력을 관리함
  • CI의 핵심 목표: "로컬에서만 잘 돌아가는 코드" → "모든 환경에서 안정적으로 작동하도록"

2. 개발이 끝나면 테스트를 해본다

1. 로컬 테스트를 해봐야 함

  • 개발 환경 → 윈도우, jdk21, mysql
  • postman 으로 api 주소를 다 요청해 보면서 test
    • notion image

      2. 서비스할 서버에서 테스트 해본다

notion image
  • CD → 지속적 배포
    • 코드를 수정하고 서비스 서버에 계속 다시 배포한다
  • AWS 환경에서 잘 동작하는지 확인 해야 한다
  1. github 에서 받아서 빌드하고 실행한다
  1. postman 으로 테스트 해본다(모든 주소 실행)
    1. notion image
  1. 문제가 생기면 다시 코드를 짜고 github 에 올린다
  1. 다시 1번으로 돌아간다

⚠️ 로컬에서 잘 되더라도 서버에선 잘 안 될 수 있다?

로컬 환경 = 개인 개발 환경 (Windows, 내 JDK, 내 DB)
서버 환경 = 운영 환경 (리눅스, 다른 설정, 클라우드 IP 등)
  • 로컬에서는 모든 게 내 기준으로 돌아감 → 운영 서버와는 환경이 다름
  • 그래서 "로컬에선 되는데 서버에선 안 됨" 현상이 잦음
  • 이를 해결하기 위해 필요한 것이 테스트 서버 + 배포 자동화 시스템

3. 직접 테스트를 하는 것은 너무 힘들다

1. 통합 테스트 코드를 작성해서 처리한다

  • postman 을 사용하여 하나하나 테스트를 하는 것은 너무 힘들 일이다
  • 테스트 코드를 작성하여 자동으로 확인되게 만들어야 한다
  • Controller 통합 테스트 코드를 작성한다
  • 테스트 코드가 포함된 코드를 서비스 서버에 배포하면 빌드할 때 자동으로 테스트 코드가 실행된다
    • 대부분의 빌드 프로그램들은 빌드 시 자동으로 테스트 코드가 실행된다
    • 에러가 발생하면 빌드가 취소된다
  • 여기서 에러가 발생하면 빌드는 실패하고 배포가 되지 않는다
  • 배포에 실패하면 다시 코드를 수정하고 서버에 배포한다

2. 테스트 서버를 만들어야 하는 이유

notion image
테스트 서버가 없다면
  • 매번 새로 배포할 때마다 서버가 재시작된다.
  • 테스트 코드가 없다면 배포 완료 후 테스트를 하고 문제가 생기면 다시 배포한다. 이때마다 서버는 껐다 켜야 한다
  • 잘 돌아가는 원래 서버에 수정된 코드를 올려서 배포하고 테스트를 하면 테스트를 하는 동안 원래 서버는 꺼진 상태다. 서비스 불가
  • 서버가 여러 대라면 각각의 서버를 모두 테스트해야 한다.
테스트 서버가 있다면
  • 테스트를 위해 잘 돌아가는 원래의 서버를 끌 필요가 없다
  • 이 서버에 배포한 뒤 테스트를 실행하고 문제가 없으면 바로 본 서비스 서버에 배포한다
  • 서버가 여러 대라도 한번만 테스트를 실행하면 된다

실습

통합 테스트에서 테스트하기 힘든 유형
  • 날짜
  • 토큰 값
통합 테스트에서 컨트롤러 테스트는 filter → dispatcher → controller 로 들어오는 구조로 실행된다

1. 통합 테스트 패키지 생성

notion image

2. 테스트 코드 작성

1. 테스트 클래스 세팅

package shop.mtcoding.blog.integre; // 컨트롤러 통합 테스트 @AutoConfigureMockMvc // MockMvc 클래스가 IoC에 로드 | RestTemplate -> 자바스크립트의 fetch와 동일, 진짜 환경에 요청 가능 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) // MOCK -> 가짜 환경을 만들어 필요한 의존관계를 다 메모리에 올려서 테스트 public class UserControllerTest { @Autowired private ObjectMapper om; // json <-> java Object 변환 해주는 객체. IoC에 objectMapper가 이미 떠있음 @Autowired private MockMvc mvc; // 가짜 환경에서 fetch 요청하는 클래스 | RestTemplate -> 자바스크립트의 fetch와 동일, 진짜 환경에 요청 가능 }
  • @AutoConfigureMockMvc → 컨트롤러 통합 테스트 어노테이션
    • MockMvc 객체가 IoC 에 로드된다
      • gpt 설명 추가
      • 가상 환경에서 HTTP 요청하는 객체
      • RestTemplate -> 자바스크립트의 fetch와 동일, 실제 환경에 요청 가능
  • @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) → MOCK : 가상 환경을 만들어 필요한 의존 관계를 다 메모리에 올려준다
    • gpt 설명 추가
  • @Autowired → 타입을 기준으로 스프링이 의존성 주입 해줌
  • ObjectMapper → json ↔ java Object 변환 해주는 객체
    • @RestController → 를 사용할 때 자동으로 json 으로 변환 해주는 객체임
      • 이미 IoC에 로드 되어 있음. new 할 필요 없음
  • MockMvc → 가상의 환경에서 HTTP 요청하는 객체
 

2. 테스트 메서드 작성

  • 컨벤션 → 실제 사용되는 <메서드이름 + _test>
  • 컨벤션을 잘 지켰다면 api 문서는 자동으로 만들어준다
 
@Test public void join_test() throws Exception { // 이 메서드를 호출한 주체에게 예외 위임 -> 지금은 jvm 이다 // given -> 가짜 데이터 UserRequest.JoinDTO reqDTO = new UserRequest.JoinDTO(); reqDTO.setEmail("haha@nate.com"); reqDTO.setPassword("1234"); reqDTO.setUsername("haha"); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // {"username":"haha","password":"1234","email":"haha@nate.com"} // when -> 테스트 실행 ResultActions actions = mvc.perform( // 주소가 틀리면 터지고, json 아닌거 넣으면 터지고, 타입이 달라도 터지고. 따라서 미리 터진다고 알려줌 MockMvcRequestBuilders.post("/join").content(requestBody).contentType(MediaType.APPLICATION_JSON) ); // eye -> 결과 눈으로 검증 String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // {"status":200,"msg":"성공","body":{"id":4,"username":"haha","email":"haha@nate.com","createdAt":"2025-05-13 11:45:23.604577"}} // then -> 결과를 코드로 검증 // json의 최상위 객체를 $ 표기한다 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(4)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.username").value("haha")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.email").value("haha@nate.com")); }
  • throws Exception → 이 메서드를 호출한 주체에게 예외 처리 위임. 여기선 jvm 이다
 
// given -> 가짜 데이터 UserRequest.JoinDTO reqDTO = new UserRequest.JoinDTO(); reqDTO.setEmail("haha@nate.com"); reqDTO.setPassword("1234"); reqDTO.setUsername("haha");
  • 가상의 reqDTO 를 만들어 given 데이터로 사용한다
 
String requestBody = om.writeValueAsString(reqDTO); System.out.println(requestBody); // {"username":"haha","password":"1234","email":"haha@nate.com"}
  • om.writeValueAsString → java Object 를 json 으로 변환
  • System.out.println → 꼭 눈으로 확인하자
    • notion image
       
// when -> 테스트 실행 ResultActions actions = mvc.perform( // 주소가 틀리면 터지고, json 아닌거 넣으면 터지고, 타입이 달라도 터지고. 따라서 미리 터진다고 알려줌 MockMvcRequestBuilders .post("/join") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) );
  • perform → 가상 요청을 실행, checkedException 으로 예외 문제를 미리 빨간 줄로 알려줌
  • 요청 메서드 선택
    • .get("/api/board/{id}/detail", id)
    • .post("/s/api/board")
    • .put("/s/api/board/{id}", id)
    • .delete("/s/api/reply/{id}", id)
  • .content(requestBody) → body 데이터 넣기
  • .contentType(MediaType.APPLICATION_JSON) → header의 mime-type 선택
  • 토큰을 넣고 싶다면
    • ResultActions actions = mvc.perform( MockMvcRequestBuilders .delete("/s/api/reply/{id}", id) .header("Authorization", "Bearer " + accessToken) );
    • .header("Authorization", "Bearer " + accessToken) → 토큰 넣는 방법
    •  
// eye -> 결과 눈으로 검증 String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // {"status":200,"msg":"성공","body":{"id":4,"username":"haha","email":"haha@nate.com","createdAt":"2025-05-13 11:45:23.604577"}}
  • actions.andReturn().getResponse().getContentAsString(); → 요청의 응답을 json 으로 변환
  • System.out.println(responseBody); → 눈으로 꼭 확인. 확인하고 actions.andExpect 코드를 작성해야 한다
    • notion image
 
// then -> 결과를 코드로 검증 // json의 최상위 객체를 $ 표기한다 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(4)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.username").value("haha")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.email").value("haha@nate.com"));
  • 눈으로 확인한 actions 의 로그 값을 보고 하나하나 작성한다
  • 해당 패키지 안에 있는 모든 테스트 코드가 한번에 실행 된다

3. 테스트 코드 실행 전, 실행 후 처리하고 싶은 것이 있다면…

@AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) public class BoardControllerTest { @Autowired private ObjectMapper om; @Autowired private MockMvc mvc; private String accessToken; @BeforeEach public void setUp() { // 테스트 시작 전에 실행할 코드 System.out.println("setUp"); User ssar = User.builder() .id(1) .username("ssar") .build(); accessToken = JwtUtil.create(ssar); } @AfterEach public void tearDown() { // 끝나고 나서 마무리 함수 // 테스트 후 정리할 코드 System.out.println("tearDown"); } }
  • @BeforeEach → 이 어노테이션을 사용하는 메서드는 모든 테스트 코드가 실행되기 전에 실행된다
    • 토큰과 같은 컨트롤러 테스트 전에 필요한 데이터는 이 방법으로 만들어서 사용한다
  • @AfterEach → 이 어노테이션을 사용하는 메서드는 모든 테스트 코드가 실행된 후 실행된다

4. 통합 테스트

notion image
notion image
 

3. 테스트 코드 작성 중 에러

유효성 검증 체크에서 에러발생

notion image
다음 에러가 발생한다면
notion image
⬇ 변경
notion image
  • @NotEmpty 어노테이션은 문자열, 컬렉션, 배열 등 "비어 있을 수 있는 타입"에만 적용 가능한데, 지금 Integer 타입에 잘못 사용되었기 때문입니다.

통합 테스트 실행 후 에러 발생(트랜젝션 에러)

  • 개별 실행 시 문제가 없었다
  • 통합으로 한번에 실행하니 문제가 발생
getBoardOne_test 실행 중 발생
{"status":200,"msg":"성공","body":{"id":1,"title":"제1","content":"내1","isPublic":true,"userId":1,"createdAt":"2025-05-13 18:10:15.689923"}} tearDown MockHttpServletRequest: HTTP Method = GET Request URI = /s/api/board/1 Parameters = {} Headers = [Authorization:"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJibG9nIiwiaWQiOjEsImV4cCI6MTc0NzEzMTAxNiwidXNlcm5hbWUiOiJzc2FyIn0.bmzGfs7tBr701URWZcFp439sCCa94vC9qyqrBIMoN-rGcqiU_sFbGAGHxnaopb9Tuvho_FutBDsGuJ3m-uij7g"] Body = null Session Attrs = {sessionUser=shop.mtcoding.blog.user.User@e60c516} Handler: Type = shop.mtcoding.blog.board.BoardController Method = shop.mtcoding.blog.board.BoardController#getBoardOne(int) Async: Async started = false Async result = null Resolved Exception: Type = null ModelAndView: View name = null View = null Model = null FlashMap: Attributes = null MockHttpServletResponse: Status = 200 Error message = null Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Content-Type:"application/json;charset=UTF-8"] Content type = application/json;charset=UTF-8 Body = {"status":200,"msg":"성공","body":{"id":1,"title":"제1","content":"내1","isPublic":true,"userId":1,"createdAt":"2025-05-13 18:10:15.689923"}} Forwarded URL = null Redirected URL = null Cookies = [] JSON path "$.body.title" Expected :제목1 Actual :제1 <Click to see difference> java.lang.AssertionError: JSON path "$.body.title" expected:<제목1> but was:<제1>
  • 예상되는 값은 “제목1” 인데 실제 나온 값은 “제1” 이다
  • 이유는 update 테스트를 실행한 뒤에 이 테스트가 실행이 되어 db에 데이터가 변경 되었기 때문이다
  • 각 테스트는 db 를 건드린 다음에는 롤백을 해야한다
notion image
  • 이 어노테이션을 붙인 뒤 문제 해결
    • notion image
  • @Transactional → test 코드에서는 자동 rollback 된다
  • ❗db에서 자동 id증가하는 메서드를 초기화 할 수 없다
    • @Sql("classpath:db/teardown.sql")
 

테스트 파일들

BoardControllerTest

package shop.mtcoding.blog.integre; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog._core.util.JwtUtil; import shop.mtcoding.blog.board.BoardRequest; import shop.mtcoding.blog.user.User; import static org.hamcrest.Matchers.*; @Transactional @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) public class BoardControllerTest { @Autowired private ObjectMapper om; @Autowired private MockMvc mvc; private String accessToken; @BeforeEach public void setUp() { // 테스트 시작 전에 실행할 코드 System.out.println("setUp"); User ssar = User.builder() .id(1) .username("ssar") .build(); accessToken = JwtUtil.create(ssar); } @AfterEach public void tearDown() { // 끝나고 나서 마무리 함수 // 테스트 후 정리할 코드 System.out.println("tearDown"); } @Test public void list_test() throws Exception { // given Integer page = 1; String keyword = "제목1"; // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .get("/") .param("page", page.toString()) .param("keyword", keyword) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.boards[0].id").value(16)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.boards[0].title").value("제목16")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.boards[0].content").value("내용16")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.boards[0].isPublic").value(true)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.boards[0].userId").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.boards[0].createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.prev").value(0)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.next").value(2)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.current").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.size").value(3)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.totalCount").value(11)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.totalPage").value(4)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isFirst").value(false)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isLast").value(false)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.numbers", hasSize(4))); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.keyword").value("제목1")); } @Test public void getBoardDetail_test() throws Exception { // given Integer id = 4; // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .get("/api/board/{id}/detail", id) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(4)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.title").value("제목4")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.content").value("내용4")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isPublic").value(true)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isBoardOwner").value(false)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isLove").value(false)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.loveCount").value(2)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.username").value("love")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.loveId").value(nullValue())); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.replies[0].id").value(3)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.replies[0].content").value("댓글3")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.replies[0].username").value("ssar")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.replies[0].isReplyOwner").value(false)); } @Test public void save_test() throws Exception { // given BoardRequest.SaveDTO reqDTO = new BoardRequest.SaveDTO(); reqDTO.setTitle("제목21"); reqDTO.setContent("내용21"); reqDTO.setIsPublic(true); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .post("/s/api/board") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(21)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.title").value("제목21")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.content").value("내용21")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isPublic").value(true)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.userId").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); } @Test public void getBoardOne_test() throws Exception { // given Integer id = 1; // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .get("/s/api/board/{id}", id) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.title").value("제목1")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.content").value("내용1")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isPublic").value(true)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.userId").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); } @Test public void update_test() throws Exception { // given Integer id = 1; BoardRequest.UpdateDTO reqDTO = new BoardRequest.UpdateDTO(); reqDTO.setTitle("제1"); reqDTO.setContent("내1"); reqDTO.setIsPublic(true); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .put("/s/api/board/{id}", id) .content(requestBody) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.title").value("제1")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.content").value("내1")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.isPublic").value(true)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.userId").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); } }

UserControllerTest

package shop.mtcoding.blog.integre; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog._core.util.JwtUtil; import shop.mtcoding.blog.user.User; import shop.mtcoding.blog.user.UserRequest; import static org.hamcrest.Matchers.matchesPattern; @Transactional // 컨트롤러 통합 테스트 @AutoConfigureMockMvc // MockMvc 클래스가 IoC에 로드 | RestTemplate -> 자바스크립트의 fetch와 동일, 진짜 환경에 요청 가능 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) // MOCK -> 가짜 환경을 만들어 필요한 의존관계를 다 메모리에 올려서 테스트 public class UserControllerTest { @Autowired private ObjectMapper om; // json <-> java Object 변환 해주는 객체. IoC에 objectMapper가 이미 떠있음 @Autowired private MockMvc mvc; // 가짜 환경에서 fetch 요청하는 클래스 | RestTemplate -> 자바스크립트의 fetch와 동일, 진짜 환경에 요청 가능 private String accessToken; @BeforeEach public void setUp() { // 테스트 시작 전에 실행할 코드 System.out.println("setUp"); User ssar = User.builder() .id(1) .username("ssar") .build(); accessToken = JwtUtil.create(ssar); } @AfterEach public void tearDown() { // 끝나고 나서 마무리 함수 // 테스트 후 정리할 코드 System.out.println("tearDown"); } @Test public void join_test() throws Exception { // 이 메서드를 호출한 주체에게 예외 위임 -> 지금은 jvm 이다 // given -> 가짜 데이터 UserRequest.JoinDTO reqDTO = new UserRequest.JoinDTO(); reqDTO.setEmail("haha@nate.com"); reqDTO.setPassword("1234"); reqDTO.setUsername("haha"); String requestBody = om.writeValueAsString(reqDTO); System.out.println(requestBody); // {"username":"haha","password":"1234","email":"haha@nate.com"} // when -> 테스트 실행 ResultActions actions = mvc.perform( // 주소가 틀리면 터지고, json 아닌거 넣으면 터지고, 타입이 달라도 터지고. 따라서 미리 터진다고 알려줌 MockMvcRequestBuilders .post("/join") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) ); // eye -> 결과 눈으로 검증 String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // {"status":200,"msg":"성공","body":{"id":4,"username":"haha","email":"haha@nate.com","createdAt":"2025-05-13 11:45:23.604577"}} // then -> 결과를 코드로 검증 // json의 최상위 객체를 $ 표기한다 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(4)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.username").value("haha")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.email").value("haha@nate.com")); } @Test public void login_test() throws Exception { // given UserRequest.LoginDTO reqDTO = new UserRequest.LoginDTO(); reqDTO.setUsername("ssar"); reqDTO.setPassword("1234"); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .post("/login") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then (jwt 길이만 검증) -> 길이 변환 가능. 패턴만 확인 actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.accessToken", matchesPattern("^[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+\\.[A-Za-z0-9-_]+$"))); } @Test public void update_test() throws Exception { // given UserRequest.UpdateDTO reqDTO = new UserRequest.UpdateDTO(); reqDTO.setEmail("ssar@gmail.com"); reqDTO.setPassword("1234"); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .put("/s/api/user") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.username").value("ssar")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.email").value("ssar@gmail.com")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); } @Test public void checkUsernameAvailable_test() throws Exception { // given String username = "ssar"; // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .get("/api/check-username-available/{username}", username) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.available").value(false)); } }

ReplyControllerTest

package shop.mtcoding.blog.integre; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog._core.util.JwtUtil; import shop.mtcoding.blog.reply.ReplyRequest; import shop.mtcoding.blog.user.User; import static org.hamcrest.Matchers.matchesPattern; import static org.hamcrest.Matchers.nullValue; @Transactional @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) public class ReplyControllerTest { @Autowired private ObjectMapper om; @Autowired private MockMvc mvc; private String accessToken; @BeforeEach public void setUp() { // 테스트 시작 전에 실행할 코드 System.out.println("setUp"); User ssar = User.builder() .id(1) .username("ssar") .build(); accessToken = JwtUtil.create(ssar); } @AfterEach public void tearDown() { // 끝나고 나서 마무리 함수 // 테스트 후 정리할 코드 System.out.println("tearDown"); } @Test public void save_test() throws Exception { // given ReplyRequest.SaveDTO reqDTO = new ReplyRequest.SaveDTO(); reqDTO.setBoardId(1); reqDTO.setContent("댓글6"); String requestBody = om.writeValueAsString(reqDTO); System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .post("/s/api/reply") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.id").value(6)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.content").value("댓글6")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.userId").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.boardId").value(1)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d+"))); } @Test public void delete_test() throws Exception { // given Integer id = 1; // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .delete("/s/api/reply/{id}", id) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body").value(nullValue())); } }

LoveControllerTest

package shop.mtcoding.blog.integre; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; 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.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import org.springframework.transaction.annotation.Transactional; import shop.mtcoding.blog._core.util.JwtUtil; import shop.mtcoding.blog.love.LoveRequest; import shop.mtcoding.blog.user.User; @Transactional @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) public class LoveControllerTest { @Autowired private ObjectMapper om; @Autowired private MockMvc mvc; private String accessToken; @BeforeEach public void setUp() { // 테스트 시작 전에 실행할 코드 System.out.println("setUp"); User ssar = User.builder() .id(1) .username("ssar") .build(); accessToken = JwtUtil.create(ssar); } @AfterEach public void tearDown() { // 끝나고 나서 마무리 함수 // 테스트 후 정리할 코드 System.out.println("tearDown"); } @Test public void saveLove_test() throws Exception { // given LoveRequest.SaveDTO reqDTO = new LoveRequest.SaveDTO(); reqDTO.setBoardId(3); String requestBody = om.writeValueAsString(reqDTO); // System.out.println(requestBody); // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .post("/s/api/love") .content(requestBody) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.loveId").value(4)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.loveCount").value(1)); } @Test public void deleteLove_test() throws Exception { // given Integer id = 1; // when ResultActions actions = mvc.perform( MockMvcRequestBuilders .delete("/s/api/love/{id}", id) .header("Authorization", "Bearer " + accessToken) ); // eye String responseBody = actions.andReturn().getResponse().getContentAsString(); System.out.println(responseBody); // then actions.andExpect(MockMvcResultMatchers.jsonPath("$.status").value(200)); actions.andExpect(MockMvcResultMatchers.jsonPath("$.msg").value("성공")); actions.andExpect(MockMvcResultMatchers.jsonPath("$.body.loveCount").value(0)); } }

서비스 테스트 할 때

좋은 질문입니다. @Mock@MockBean은 모두 Mockito를 이용해 Mock 객체를 만들기 위한 어노테이션이지만, 적용 범위와 목적이 다릅니다.

@Mock vs @MockBean 차이 정리

구분
@Mock
@MockBean
목적
단순 Mockito Mock 객체 생성
Spring 컨텍스트 안의 Bean을 Mock으로 교체
사용 대상
단위 테스트 (JUnit + Mockito)
SpringBootTest 통합 테스트
동작 방식
단순한 가짜 객체 생성
스프링 컨테이너에 있는 실제 Bean을 가짜로 덮어쓰기
Spring Context 필요 여부
❌ 필요 없음
✅ Spring Context 필요
주로 사용되는 상황
순수 단위 테스트 (Service만 테스트)
통합 테스트 (Service + Bean 간 연동 포함)

✅ 예시 코드 비교

1. @Mock (단위 테스트에서)

@ExtendWith(MockitoExtension.class) public class LottoServiceUnitTest { @Mock private LottoRepository lottoRepository; @InjectMocks private LottoService lottoService; @Test public void someTest() { when(lottoRepository.findAll()).thenReturn(List.of()); lottoService.doSomething(); verify(lottoRepository).findAll(); } }
  • MockitoExtension 사용
  • Spring Context 로딩 안 함
  • Service와 내부 Mock만 테스트

2. @MockBean (SpringBootTest에서)

@SpringBootTest public class LottoSchedulerServiceTest { @Autowired private LottoSchedulerService lottoSchedulerService; @MockBean private LottoRepository lottoRepository; @Test public void someTest() { when(lottoRepository.findAll()).thenReturn(List.of()); lottoSchedulerService.checkWinners(); verify(lottoRepository).findAll(); } }
  • Spring Context 실제로 로딩됨
  • Spring Bean 등록된 LottoRepository를 Mock으로 대체
  • Service 뿐 아니라 연관된 실제 Bean 흐름까지 테스트 가능

💡 결론

  • @MockMockito 기반 단위 테스트용
    • 빠름, 가볍고 간단함
  • @MockBeanSpring Boot 통합 테스트에서 기존 빈을 Mock으로 대체
    • 전체 흐름을 통제하면서 내부 의존성 행위를 검증할 때 적합
Share article

jjack1