Slice Test와 테스트 전략 - Spring Boot에서 필요한 부분만 테스트하기
TL;DR
- Slice Test는 애플리케이션의 특정 계층만 잘라 빠르게 테스트한다.
- @WebMvcTest, @DataJpaTest 등 전용 슬라이스 애노테이션이 핵심이다.
- 전체 로딩보다 빠르고, 단위 테스트보다 실제 구성에 가깝다.
1. 개념
Slice Test는 Spring Boot에서 웹, 데이터, JSON 등 특정 계층만 로드하여 빠르게 검증하는 테스트 방식이다.
2. 배경
테스트 피라미드 원칙과 함께 @SpringBootTest의 느린 전체 컨텍스트 로딩이 병목이 되면서 계층별 테스트 전략이 필요해졌다.
3. 이유
테스트 속도를 높이면서도 실제 구성과 가까운 검증을 수행하기 위해 필요한 Bean만 로드하는 접근이 도입되었다.
4. 특징
@WebMvcTest, @DataJpaTest, @JsonTest와 @MockBean 활용, 계층별 자동 설정 분리, 빠른 피드백이 특징이다.
5. 상세 내용
Slice Test와 테스트 전략 - Spring Boot에서 “필요한 부분만” 테스트하기
작성일: 2026-02-26 카테고리: Backend / Testing / Spring Boot 포함 내용: Slice Test, @WebMvcTest, @DataJpaTest, @SpringBootTest, Test Pyramid, Unit Test, Integration Test, @MockBean, TestContainers, ApplicationContext, Auto-Configuration
1. 테스트가 왜 필요한가? (완전 기초부터)
1.1 테스트 = “내가 만든 코드가 제대로 동작하는지 자동으로 확인하는 코드”
┌─────────────────────────────────────────────────────────────────┐
│ 테스트란 무엇인가? (완전 기초) │
│ │
│ 비유: 자동차 공장의 출고 전 검사 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 자동차 공장에서는 출고 전에 반드시 검사한다: │ │
│ │ ├── 엔진 시동이 걸리는가? │ │
│ │ ├── 브레이크가 제대로 작동하는가? │ │
│ │ ├── 에어백이 터지는가? │ │
│ │ └── 조향장치가 정상인가? │ │
│ │ │ │
│ │ 수동으로 매번 확인? → 시간이 너무 오래 걸린다 │ │
│ │ 자동 검사 라인? → 빠르고 정확하게 반복 가능! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 코드도 마찬가지: │
│ ├── 코드 수정할 때마다 브라우저로 직접 확인? → 비효율 │
│ ├── 기능이 100개면 100번 클릭? → 현실적으로 불가능 │
│ └── 테스트 코드를 작성하면 → 버튼 하나로 전부 자동 검증! │
│ │
│ 테스트 코드 = 내 코드를 검사하는 또 다른 코드 │
│ "이 함수에 3을 넣으면 6이 나와야 해" → 자동으로 확인 │
│ │
└─────────────────────────────────────────────────────────────────┘
1.2 테스트의 종류 (자동차 검사에 비유)
┌─────────────────────────────────────────────────────────────────┐
│ 테스트의 종류 (비유로 이해하기) │
│ │
│ 자동차 검사에 대응시키면: │
│ │
│ 1. 부품 검사 = Unit Test (단위 테스트) │
│ ├── 엔진만 따로 꺼내서 검사 │
│ ├── 다른 부품 없이 엔진 혼자 동작하는지 확인 │
│ └── 가장 빠르고, 가장 많이 함 │
│ │
│ 2. 조립 검사 = Integration Test (통합 테스트) │
│ ├── 엔진 + 변속기를 연결해서 검사 │
│ ├── 부품끼리 연결했을 때 잘 동작하는지 확인 │
│ └── 시간이 좀 더 걸림 │
│ │
│ 3. 완성차 검사 = E2E Test (종단간 테스트) │
│ ├── 실제 도로에서 주행 테스트 │
│ ├── 처음부터 끝까지 전체를 테스트 │
│ └── 가장 느리고, 비용이 큼 │
│ │
│ 4. ⭐ 특정 시스템 검사 = Slice Test (슬라이스 테스트) │
│ ├── 브레이크 시스템만 전문적으로 검사 │
│ ├── 브레이크 패드 + 유압 장치 + 센서를 연결해서 검사 │
│ ├── 엔진은 빼고, 타이어도 빼고, 브레이크만! │
│ └── = "한 조각(Slice)만 잘라서 테스트" │
│ │
└─────────────────────────────────────────────────────────────────┘
2. 테스트 피라미드 (Test Pyramid) - 테스트의 기본 원칙
2.1 테스트 피라미드란?
┌─────────────────────────────────────────────────────────────────┐
│ 테스트 피라미드 (Test Pyramid) │
│ │
│ 2009년 Mike Cohn이 "Succeeding with Agile" 책에서 제안 │
│ "피라미드 모양으로 테스트를 구성하라" │
│ │
│ /\ │
│ / \ ← E2E: 적게 (느리고, 비쌈) │
│ / E2E\ │
│ /──────\ │
│ / Slice \ ← Slice: 계층별로 적절히 │
│ /──────────\ │
│ /Integration \ ← Integration: 중간 │
│ /──────────────\ │
│ / Unit Tests \ ← Unit: 많이 (빠르고, 쌈) │
│ /──────────────────\ │
│ │
│ 원칙: │
│ ├── 아래로 갈수록: 빠르고, 많이, 저렴하게 │
│ ├── 위로 갈수록: 느리고, 적게, 비싸게 │
│ └── 피라미드를 뒤집으면(E2E가 많으면) → 느리고 불안정 │
│ │
└─────────────────────────────────────────────────────────────────┘
2.2 각 단계 상세 설명
┌─────────────────────────────────────────────────────────────────┐
│ Unit Test (단위 테스트) │
│ │
│ 정의: 함수나 메서드 "하나"를 고립시켜서 테스트 │
│ │
│ 특징: │
│ ├── 외부 의존성 없음 (DB 없음, 네트워크 없음) │
│ ├── 매우 빠름 (밀리초 단위, 1000분의 1초) │
│ ├── JUnit 5, Kotest 등의 프레임워크 사용 │
│ └── Spring을 아예 띄우지 않음 → 순수 Java/Kotlin만 실행 │
│ │
│ 비유: 전구 하나를 소켓에 끼워서 불 켜지는지만 확인 │
│ 다른 전기 시스템과 연결하지 않고 전구만 테스트 │
│ │
│ 예시: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // "가격 계산" 함수만 단독으로 테스트 │ │
│ │ @Test │ │
│ │ fun `할인율 10%면 1000원이 900원이 된다`() { │ │
│ │ val result = calculateDiscount(1000, 10) │ │
│ │ assertThat(result).isEqualTo(900) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Integration Test (통합 테스트) │
│ │
│ 정의: 여러 컴포넌트(부품)를 연결해서 함께 테스트 │
│ │
│ 컴포넌트(Component)란? │
│ = 프로그램을 구성하는 독립적인 부품 │
│ = Controller, Service, Repository 등 각각이 하나의 컴포넌트 │
│ │
│ 특징: │
│ ├── 실제 DB, 실제 네트워크 사용 가능 │
│ ├── 느림 (초~분 단위) │
│ ├── @SpringBootTest 사용 → Spring 전체를 띄움 │
│ └── 실제 환경과 가장 비슷하게 테스트 │
│ │
│ 비유: 조명 시스템 전체를 켜서 │
│ 스위치 → 배선 → 전구 순서로 전류가 흐르는지 확인 │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ E2E Test (End-to-End, 종단간 테스트) │
│ │
│ 정의: 사용자 관점에서 전체 시스템을 처음부터 끝까지 테스트 │
│ │
│ End-to-End = "끝에서 끝까지" │
│ = 사용자가 브라우저에서 버튼을 누르면 │
│ → 서버에 요청이 가고 │
│ → DB에서 데이터를 가져오고 │
│ → 화면에 결과가 나타나는 것까지 전부 테스트 │
│ │
│ 특징: │
│ ├── 브라우저 자동화 도구 사용 (Selenium, Playwright) │
│ ├── 매우 느리고, 깨지기 쉬움 │
│ ├── 실제 서버 + 실제 DB + 실제 브라우저 필요 │
│ └── 핵심 시나리오만 최소한으로 작성 │
│ │
│ 비유: 실제로 집에 들어가서 전등 스위치를 눌러보는 것 │
│ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ⭐ Slice Test (슬라이스 테스트) = 이 문서의 핵심! │
│ │
│ 정의: "애플리케이션의 특정 계층(Slice)만 잘라서 테스트" │
│ │
│ Slice = 조각, 한 겹 │
│ 애플리케이션을 케이크처럼 여러 층으로 보고, │
│ 그 중 한 층만 잘라서(Slice) 테스트한다! │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 애플리케이션의 계층 구조 │ │
│ │ │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ Controller (웹 계층) │ ← @WebMvcTest │ │
│ │ ├────────────────────────────────┤ │ │
│ │ │ Service (비즈니스 로직) │ ← Unit Test │ │
│ │ ├────────────────────────────────┤ │ │
│ │ │ Repository (DB 접근 계층) │ ← @DataJpaTest │ │
│ │ └────────────────────────────────┘ │ │
│ │ │ │
│ │ 각 계층을 "슬라이스"해서 독립적으로 테스트! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 특징: │
│ ├── Unit과 Integration 사이의 "딱 맞는" 테스트 │
│ ├── 필요한 Bean만 로드 → 빠르면서도 실제적 │
│ ├── Spring Boot 1.4 (2016년)에서 처음 도입 │
│ └── @WebMvcTest, @DataJpaTest 등의 어노테이션 사용 │
│ │
│ Bean이란? │
│ = Spring이 생성하고 관리하는 객체 (나중에 자세히 설명) │
│ │
└─────────────────────────────────────────────────────────────────┘
3. Slice Test가 나오게 된 배경
3.1 핵심 용어부터 정리
┌─────────────────────────────────────────────────────────────────┐
│ 이해를 위해 꼭 알아야 할 용어 정리 │
│ │
│ 1. Spring Framework │
│ = Java/Kotlin으로 웹 서버를 만들 때 쓰는 도구 모음 │
│ = 비유: 요리할 때 쓰는 주방 도구 세트 │
│ │
│ 2. Spring Boot │
│ = Spring Framework를 더 쉽게 쓸 수 있게 만든 것 │
│ = 비유: 주방 도구 세트 + 자동 세팅 (오븐 예열 자동 등) │
│ │
│ 3. Bean (빈) │
│ = Spring이 생성하고 관리하는 객체(Object) │
│ = Controller, Service, Repository 등이 각각 Bean │
│ = 비유: Spring이라는 공장에서 만들어 관리하는 부품들 │
│ │
│ 4. ApplicationContext (애플리케이션 컨텍스트) │
│ = Spring이 관리하는 "Bean들의 컨테이너(보관함)" │
│ = 모든 Bean이 여기에 등록되고, 필요할 때 꺼내 씀 │
│ = 비유: 공구함. 각종 공구(Bean)가 들어있고 │
│ 필요할 때 "드라이버 줘" 하면 꺼내줌 │
│ │
│ 5. Auto-Configuration (자동 설정) │
│ = Spring Boot가 자동으로 필요한 설정을 해주는 것 │
│ = 비유: 호텔 체크인하면 방에 이미 수건, 비누, │
│ 침대가 준비되어 있는 것 │
│ = 개발자가 일일이 설정하지 않아도 자동으로 세팅! │
│ │
│ 6. 어노테이션 (Annotation, @로 시작하는 것) │
│ = 코드에 붙이는 "표시" 또는 "라벨" │
│ = @Controller → "이 클래스는 컨트롤러야" 라고 표시 │
│ = @Test → "이 메서드는 테스트야" 라고 표시 │
│ = 비유: 택배 상자에 "취급주의" 스티커 붙이는 것 │
│ │
└─────────────────────────────────────────────────────────────────┘
3.2 @SpringBootTest의 등장과 한계
┌─────────────────────────────────────────────────────────────────┐
│ @SpringBootTest의 편리함과 한계 │
│ │
│ Spring Boot 이전 (고통의 시대): │
│ ├── XML 설정 파일을 수동으로 작성 │
│ ├── Bean을 하나하나 수동 등록 │
│ ├── 테스트용 설정을 따로 관리 │
│ └── 매우 번거롭고 느림 │
│ │
│ Spring Boot가 @SpringBootTest를 도입: │
│ ├── 한 줄이면 전체 ApplicationContext 로드! │
│ ├── 설정 자동 → 매우 편리! │
│ └── 하지만... 전체를 다 로드하니까 느림! │
│ │
│ 비유로 이해하기: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 상황: 엔진만 테스트하고 싶다 │ │
│ │ │ │
│ │ @SpringBootTest 방식: │ │
│ │ "엔진 테스트하려면 자동차 전체를 조립해야 합니다" │ │
│ │ → 에어백, 오디오, 네비, 시트 히터까지 전부 장착 │ │
│ │ → 그제서야 엔진 테스트 시작 │ │
│ │ → 시간 낭비! │ │
│ │ │ │
│ │ 실제 수치: │ │
│ │ ├── Bean이 200개인 프로젝트 │ │
│ │ ├── Controller 하나 테스트하는데 30초 이상 │ │
│ │ ├── 테스트가 50개면 → 30초 x 50 = 25분! │ │
│ │ └── 개발자: 커피 마시러 감... │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
3.3 Slice Test의 탄생 (Spring Boot 1.4, 2016)
┌─────────────────────────────────────────────────────────────────┐
│ Slice Test의 탄생 배경 │
│ │
│ Spring Boot 팀 (Phil Webb, Andy Wilkinson 등)의 해결책: │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ "전체 다 로드하지 말고, 필요한 계층만 로드하자!" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 아이디어: Auto-Configuration을 계층별로 분리 │
│ │
│ @SpringBootTest 방식: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ApplicationContext에 모든 Bean을 다 넣음 │ │
│ │ [Controller][Service][Repository][Security][Cache]... │ │
│ │ → 공구함에 모든 공구를 전부 넣음 → 무겁고 느림 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ @WebMvcTest 방식 (Slice Test): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ApplicationContext에 웹 관련 Bean만 넣음 │ │
│ │ [Controller][Filter][WebConfig] │ │
│ │ → 공구함에 웹 관련 공구만 넣음 → 가볍고 빠름! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 속도 비교: │
│ ├── @SpringBootTest → 5~30초 (전체 로드) │
│ ├── @WebMvcTest → 1~3초 (웹 계층만) │
│ └── @DataJpaTest → 2~5초 (DB 계층만) │
│ │
│ 2016년 Spring Boot 1.4에서 공식 도입 │
│ 이후 테스트 작성의 표준이 됨 │
│ │
└─────────────────────────────────────────────────────────────────┘
4. Spring Boot Slice Test 종류 - 하나하나 상세 설명
4.1 @WebMvcTest - 웹 계층(Controller) 테스트
┌─────────────────────────────────────────────────────────────────┐
│ @WebMvcTest - 웹 계층(Controller)만 테스트 │
│ │
│ 이게 뭔가? │
│ = Controller만 테스트한다. │
│ = Service, Repository는 아예 로드하지 않는다. │
│ = 실제 HTTP 서버를 띄우지 않고, 요청/응답을 시뮬레이션한다. │
│ │
│ Controller란? │
│ = 사용자의 HTTP 요청을 받아서 처리하는 클래스 │
│ = "GET /users/1 요청이 오면 1번 사용자 정보를 반환해라" │
│ = 비유: 식당의 웨이터. 손님 주문을 받아서 주방에 전달 │
│ │
│ 로드되는 것: │
│ ├── @Controller, @RestController (요청 처리기) │
│ ├── @ControllerAdvice (에러 처리기) │
│ ├── @JsonComponent (JSON 변환기) │
│ ├── Filter (요청 필터) │
│ └── WebMvcConfigurer (웹 설정) │
│ │
│ 로드 안 되는 것: │
│ ├── @Service (비즈니스 로직) │
│ ├── @Repository (DB 접근) │
│ └── @Component (기타 Bean) │
│ │
│ 왜 쓰나? │
│ ├── "GET /users 요청 시 200 OK를 반환하는가?" │
│ ├── "잘못된 요청이 오면 400 Bad Request를 반환하는가?" │
│ ├── "인증 없이 접근하면 401을 반환하는가?" │
│ └── "응답 JSON 형식이 올바른가?" │
│ │
└─────────────────────────────────────────────────────────────────┘
Kotlin 코드 예시
┌─────────────────────────────────────────────────────────────────┐
│ @WebMvcTest 코드 예시 (Kotlin) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @WebMvcTest(UserController::class) │ │
│ │ class UserControllerTest { │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var mockMvc: MockMvc │ │
│ │ │ │
│ │ @MockBean │ │
│ │ lateinit var userService: UserService │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `사용자 조회 성공`() { │ │
│ │ // Given │ │
│ │ given(userService.getUser(1L)) │ │
│ │ .willReturn( │ │
│ │ UserDto(1L, "Kim", "kim@test.com") │ │
│ │ ) │ │
│ │ │ │
│ │ // When & Then │ │
│ │ mockMvc.perform(get("/users/1")) │ │
│ │ .andExpect(status().isOk) │ │
│ │ .andExpect( │ │
│ │ jsonPath("$.name").value("Kim") │ │
│ │ ) │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 코드 한 줄 한 줄 설명: │
│ │
│ @WebMvcTest(UserController::class) │
│ └── "UserController만 로드해라" │
│ → Service, Repository는 로드하지 않음 │
│ │
│ @Autowired lateinit var mockMvc: MockMvc │
│ ├── @Autowired = "Spring이 자동으로 주입해줘" │
│ ├── MockMvc = 실제 HTTP 서버 없이 요청을 시뮬레이션하는 도구 │
│ └── 비유: 진짜 손님 없이 주문서를 직접 작성해서 테스트 │
│ │
│ @MockBean lateinit var userService: UserService │
│ └── "진짜 UserService 대신 가짜(Mock)를 넣어라" │
│ → 이유: 바로 아래에서 상세 설명 │
│ │
│ given(userService.getUser(1L)).willReturn(...) │
│ └── "getUser(1)이 호출되면 이 값을 반환하라"고 설정 │
│ → 가짜 Service에게 "이렇게 행동해" 라고 지시 │
│ │
│ mockMvc.perform(get("/users/1")) │
│ └── "GET /users/1 요청을 보내라" (시뮬레이션) │
│ │
│ .andExpect(status().isOk) │
│ └── "응답 상태가 200 OK인지 확인해라" │
│ │
│ .andExpect(jsonPath("$.name").value("Kim")) │
│ └── "응답 JSON의 name 필드가 Kim인지 확인해라" │
│ │
└─────────────────────────────────────────────────────────────────┘
@MockBean 상세 설명
┌─────────────────────────────────────────────────────────────────┐
│ @MockBean 상세 설명 (가짜 객체) │
│ │
│ Mock = 가짜, 모형 │
│ @MockBean = "진짜 Bean 대신 가짜 Bean을 Spring에 등록해라" │
│ │
│ 왜 가짜를 쓰나? │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Controller를 테스트하는데 진짜 Service를 쓰면... │ │
│ │ │ │
│ │ Controller → Service(진짜) → Repository(진짜) → DB │ │
│ │ │ │
│ │ → Service가 DB를 호출함 │ │
│ │ → DB도 준비해야 함 │ │
│ │ → 점점 무거워짐 │ │
│ │ → "컨트롤러만 테스트하고 싶은데...!" │ │
│ │ │ │
│ │ 가짜 Service를 쓰면: │ │
│ │ Controller → Service(가짜) → (끝! DB 불필요!) │ │
│ │ │ │
│ │ "getUser(1) 호출되면 이 값을 줘" 하고 미리 설정 │ │
│ │ → DB 없이 빠르게 테스트 가능! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 비유: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 영화 촬영에서: │ │
│ │ ├── 진짜 폭발 → 위험하고 비쌈 │ │
│ │ ├── CG(컴퓨터 그래픽) → 안전하고 저렴 │ │
│ │ └── CG = Mock (가짜지만 목적에 충분) │ │
│ │ │ │
│ │ 테스트에서: │ │
│ │ ├── 진짜 Service → DB도 필요, 느림 │ │
│ │ ├── Mock Service → DB 불필요, 빠름 │ │
│ │ └── Mock = CG (가짜지만 Controller 테스트에 충분) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ @MockBean vs @Mock 차이: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @Mock (Mockito 라이브러리): │ │
│ │ ├── 순수 가짜 객체 생성 │ │
│ │ ├── Spring과 무관 │ │
│ │ └── Unit Test에서 주로 사용 │ │
│ │ │ │
│ │ @MockBean (Spring Boot): │ │
│ │ ├── 가짜 객체를 생성 + Spring ApplicationContext에 등록 │ │
│ │ ├── 진짜 Bean을 대체함 │ │
│ │ └── Slice Test에서 주로 사용 │ │
│ │ │ │
│ │ 핵심 차이: @MockBean은 Spring이 관리하는 가짜 │ │
│ │ @Mock은 개발자가 직접 관리하는 가짜 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
@WebMvcTest 실전: 에러 처리 테스트
┌─────────────────────────────────────────────────────────────────┐
│ @WebMvcTest로 에러 상황 테스트하기 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @Test │ │
│ │ fun `존재하지 않는 사용자 조회 시 404`() { │ │
│ │ // Given: 예외를 던지도록 설정 │ │
│ │ given(userService.getUser(999L)) │ │
│ │ .willThrow(UserNotFoundException(999L)) │ │
│ │ │ │
│ │ // When & Then: 404 Not Found 확인 │ │
│ │ mockMvc.perform(get("/users/999")) │ │
│ │ .andExpect(status().isNotFound) │ │
│ │ .andExpect( │ │
│ │ jsonPath("$.message") │ │
│ │ .value("User 999 not found") │ │
│ │ ) │ │
│ │ } │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `잘못된 요청 시 400`() { │ │
│ │ // When & Then: 이메일 없이 요청 → 400 │ │
│ │ mockMvc.perform( │ │
│ │ post("/users") │ │
│ │ .contentType(MediaType.APPLICATION_JSON) │ │
│ │ .content("""{"name": ""}""") │ │
│ │ ) │ │
│ │ .andExpect(status().isBadRequest) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ❌ 이런 것은 @WebMvcTest로 테스트하면 안 됨: │
│ ├── DB에 데이터가 실제로 저장되는지 │
│ ├── Service 로직이 올바른지 │
│ └── 여러 API를 연속으로 호출하는 시나리오 │
│ │
│ ✅ 이런 것이 @WebMvcTest에 적합: │
│ ├── 요청 URL/메서드가 올바르게 매핑되는지 │
│ ├── 요청 검증(Validation)이 작동하는지 │
│ ├── 응답 JSON 형식이 올바른지 │
│ └── 에러 시 적절한 HTTP 상태 코드를 반환하는지 │
│ │
└─────────────────────────────────────────────────────────────────┘
4.2 @DataJpaTest - JPA(데이터베이스) 계층 테스트
┌─────────────────────────────────────────────────────────────────┐
│ @DataJpaTest - JPA(데이터베이스) 계층만 테스트 │
│ │
│ 먼저 용어부터: │
│ │
│ JPA (Java Persistence API)란? │
│ = Java/Kotlin에서 DB에 데이터를 저장/조회하는 표준 방법 │
│ = SQL을 직접 쓰지 않고, 객체를 DB에 저장할 수 있게 해줌 │
│ = 비유: 통역사. 한국어(Java 객체) ↔ 영어(SQL) 번역 │
│ │
│ Repository란? │
│ = DB에 접근하는 코드를 모아놓은 클래스 │
│ = "사용자 저장", "사용자 검색", "사용자 삭제" 등 │
│ = 비유: 도서관 사서. "이 책 찾아줘", "이 책 넣어줘" │
│ │
│ @Entity란? │
│ = "이 클래스는 DB 테이블에 대응하는 클래스야" 라는 표시 │
│ = User 클래스에 @Entity → users 테이블과 연결 │
│ │
│ @DataJpaTest가 하는 일: │
│ ├── @Entity 클래스들을 로드 │
│ ├── @Repository 클래스들을 로드 │
│ ├── JPA 관련 설정만 로드 │
│ ├── 내장 DB(H2)를 자동으로 사용 (설치 불필요!) │
│ ├── 각 테스트 후 자동으로 롤백 (데이터 원상복구) │
│ └── @Controller, @Service는 로드하지 않음 │
│ │
│ 내장 DB(H2)란? │
│ = 별도 설치 없이 메모리에서 실행되는 가벼운 DB │
│ = 테스트 끝나면 자동으로 사라짐 │
│ = 비유: 연습장. 아무거나 써도 되고 다 쓰면 버리면 됨 │
│ │
└─────────────────────────────────────────────────────────────────┘
Kotlin 코드 예시
┌─────────────────────────────────────────────────────────────────┐
│ @DataJpaTest 코드 예시 (Kotlin) │
│ │
│ 먼저 Entity 클래스: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @Entity │ │
│ │ @Table(name = "users") │ │
│ │ class User( │ │
│ │ @Id @GeneratedValue(strategy = IDENTITY) │ │
│ │ val id: Long = 0, │ │
│ │ val name: String, │ │
│ │ val email: String │ │
│ │ ) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Repository 인터페이스: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ interface UserRepository : JpaRepository<User, Long> { │ │
│ │ fun findByName(name: String): User? │ │
│ │ fun findByEmailContaining(keyword: String): │ │
│ │ List<User> │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 테스트 코드: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @DataJpaTest │ │
│ │ class UserRepositoryTest { │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var userRepository: UserRepository │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var entityManager: TestEntityManager │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `이름으로 사용자 검색`() { │ │
│ │ // Given: 테스트 데이터 저장 │ │
│ │ entityManager.persistAndFlush( │ │
│ │ User(name = "Kim", email = "kim@test.com") │ │
│ │ ) │ │
│ │ │ │
│ │ // When: 이름으로 검색 │ │
│ │ val found = userRepository.findByName("Kim") │ │
│ │ │ │
│ │ // Then: 결과 확인 │ │
│ │ assertThat(found).isNotNull │ │
│ │ assertThat(found!!.email) │ │
│ │ .isEqualTo("kim@test.com") │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 코드 설명: │
│ │
│ @DataJpaTest │
│ └── "JPA 관련 Bean만 로드해라. 내장 DB를 사용해라." │
│ │
│ TestEntityManager │
│ └── 테스트 전용 DB 접근 도구. 데이터 직접 저장/조회 가능 │
│ │
│ persistAndFlush() │
│ ├── persist = DB에 저장 │
│ └── flush = 저장 명령을 즉시 실행 (나중에 말고 지금 당장!) │
│ │
│ 각 테스트 후 자동 롤백: │
│ ├── @DataJpaTest는 @Transactional이 자동 적용 │
│ ├── 테스트에서 저장한 데이터는 테스트 끝나면 사라짐 │
│ └── 다음 테스트에 영향 없음 = 테스트 격리 │
│ │
└─────────────────────────────────────────────────────────────────┘
@AutoConfigureTestDatabase 설명
┌─────────────────────────────────────────────────────────────────┐
│ @AutoConfigureTestDatabase 설명 │
│ │
│ @DataJpaTest는 기본적으로 내장 DB(H2)를 사용한다. │
│ 하지만 실제 DB(PostgreSQL, MySQL)로 테스트하고 싶다면? │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // 내장 DB 대신 실제 DB 사용하기 │ │
│ │ @DataJpaTest │ │
│ │ @AutoConfigureTestDatabase(replace = Replace.NONE) │ │
│ │ class UserRepositoryRealDbTest { │ │
│ │ // application.yml에 설정된 실제 DB를 사용 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Replace.NONE = "내장 DB로 교체하지 마. 원래 설정 그대로 써" │
│ │
│ ❌ 내장 DB의 한계: │
│ ├── H2는 PostgreSQL/MySQL과 SQL 문법이 다를 수 있음 │
│ ├── 특정 DB 전용 기능 (JSON 타입, 배열 등) 테스트 불가 │
│ └── "H2에서는 통과, 실제 DB에서는 실패" 문제 발생 가능 │
│ │
│ ✅ 이 문제를 해결하는 것이 → TestContainers (6장에서 설명) │
│ │
└─────────────────────────────────────────────────────────────────┘
4.3 @WebFluxTest - WebFlux(리액티브 웹) 계층 테스트
┌─────────────────────────────────────────────────────────────────┐
│ @WebFluxTest - 리액티브 웹 컨트롤러 테스트 │
│ │
│ WebFlux란? │
│ = Spring의 비동기/논블로킹 웹 프레임워크 │
│ = 기존 Spring MVC가 "한 요청당 한 스레드"라면 │
│ WebFlux는 "적은 스레드로 많은 요청 처리" │
│ = 비유: MVC = 카운터 직원이 한 명씩 처리 │
│ WebFlux = 키오스크가 여러 명 동시 처리 │
│ │
│ @WebFluxTest: │
│ ├── WebFlux Controller만 로드 │
│ ├── WebTestClient를 사용 (MockMvc 대신) │
│ └── 나머지는 @WebMvcTest와 같은 원리 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @WebFluxTest(UserController::class) │ │
│ │ class UserControllerTest { │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var webTestClient: WebTestClient │ │
│ │ │ │
│ │ @MockBean │ │
│ │ lateinit var userService: UserService │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `사용자 조회`() { │ │
│ │ given(userService.getUser(1L)) │ │
│ │ .willReturn(Mono.just(UserDto(1L, "Kim"))) │ │
│ │ │ │
│ │ webTestClient.get() │ │
│ │ .uri("/users/1") │ │
│ │ .exchange() │ │
│ │ .expectStatus().isOk │ │
│ │ .expectBody() │ │
│ │ .jsonPath("$.name").isEqualTo("Kim") │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
4.4 @JsonTest - JSON 직렬화/역직렬화 테스트
┌─────────────────────────────────────────────────────────────────┐
│ @JsonTest - JSON 변환 테스트 │
│ │
│ 직렬화(Serialization)란? │
│ = 객체 → JSON 문자열로 변환 │
│ = User(name="Kim") → {"name": "Kim"} │
│ = 비유: 택배 보내기. 물건을 박스에 포장하는 것 │
│ │
│ 역직렬화(Deserialization)란? │
│ = JSON 문자열 → 객체로 변환 │
│ = {"name": "Kim"} → User(name="Kim") │
│ = 비유: 택배 받기. 박스를 뜯어서 물건을 꺼내는 것 │
│ │
│ 왜 이것을 테스트하나? │
│ ├── 날짜 형식이 "2026-02-26"으로 나오는지 │
│ ├── null인 필드가 JSON에서 빠지는지 │
│ ├── 금액이 1000이 아니라 "1,000원"으로 나오는지 │
│ └── 커스텀 변환 로직이 올바른지 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @JsonTest │ │
│ │ class UserDtoJsonTest { │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var json: JacksonTester<UserDto> │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `직렬화 테스트`() { │ │
│ │ val user = UserDto(1L, "Kim", "kim@test.com") │ │
│ │ val result = json.write(user) │ │
│ │ │ │
│ │ assertThat(result) │ │
│ │ .extractingJsonPathStringValue("$.name") │ │
│ │ .isEqualTo("Kim") │ │
│ │ } │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `역직렬화 테스트`() { │ │
│ │ val content = """ │ │
│ │ {"id":1,"name":"Kim","email":"kim@test.com"} │ │
│ │ """.trimIndent() │ │
│ │ │ │
│ │ val result = json.parse(content) │ │
│ │ assertThat(result.object.name) │ │
│ │ .isEqualTo("Kim") │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ JacksonTester = JSON 변환 테스트 전용 도구 │
│ Jackson = Java/Kotlin에서 가장 많이 쓰는 JSON 라이브러리 │
│ │
└─────────────────────────────────────────────────────────────────┘
4.5 @RestClientTest - REST 클라이언트 테스트
┌─────────────────────────────────────────────────────────────────┐
│ @RestClientTest - 외부 API 호출 테스트 │
│ │
│ REST 클라이언트란? │
│ = 우리 서버에서 다른 서버의 API를 호출하는 코드 │
│ = 예: 우리 서버 → 카카오 API로 결제 요청 │
│ = 비유: 다른 가게에 전화해서 배달 주문하는 것 │
│ │
│ 문제: 테스트할 때 진짜 카카오 서버를 호출할 수 없음 │
│ ├── 돈이 진짜 빠져나감 │
│ ├── 카카오 서버가 느릴 수 있음 │
│ └── 네트워크 끊기면 테스트 실패 │
│ │
│ 해결: MockRestServiceServer로 외부 API를 가짜로 만듦 │
│ = "카카오 서버인 척하는 가짜 서버" │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @RestClientTest(PaymentClient::class) │ │
│ │ class PaymentClientTest { │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var client: PaymentClient │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var server: MockRestServiceServer │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `결제 요청 성공`() { │ │
│ │ // 가짜 서버가 이 응답을 반환하도록 설정 │ │
│ │ server.expect(requestTo("/pay")) │ │
│ │ .andRespond(withSuccess( │ │
│ │ """{"status":"OK"}""", │ │
│ │ MediaType.APPLICATION_JSON │ │
│ │ )) │ │
│ │ │ │
│ │ val result = client.pay(1000) │ │
│ │ assertThat(result.status).isEqualTo("OK") │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
4.6 기타 Slice Test 어노테이션
┌─────────────────────────────────────────────────────────────────┐
│ 기타 Slice Test 어노테이션들 │
│ │
│ @DataMongoTest │
│ ├── MongoDB(문서형 DB) 관련 Bean만 로드 │
│ ├── 내장 MongoDB 사용 가능 │
│ └── MongoDB Repository 테스트에 사용 │
│ │
│ @DataRedisTest │
│ ├── Redis(인메모리 캐시 DB) 관련 Bean만 로드 │
│ └── Redis 연동 코드 테스트에 사용 │
│ │
│ @DataR2dbcTest │
│ ├── R2DBC(리액티브 DB 접근) 관련 Bean만 로드 │
│ └── 비동기 DB 접근 테스트에 사용 │
│ │
│ @JdbcTest │
│ ├── JDBC(저수준 DB 접근) 관련 Bean만 로드 │
│ └── JdbcTemplate 사용 코드 테스트에 사용 │
│ │
│ @JooqTest │
│ ├── jOOQ(SQL 빌더 라이브러리) 관련 Bean만 로드 │
│ └── jOOQ 기반 쿼리 테스트에 사용 │
│ │
│ 공통점: 모두 "특정 기술 계층"만 잘라서 테스트한다 │
│ │
└─────────────────────────────────────────────────────────────────┘
5. @SpringBootTest vs Slice Test - 언제 무엇을 쓰나?
5.1 상세 비교
┌─────────────────────────────────────────────────────────────────┐
│ @SpringBootTest vs Slice Test 비교 │
│ │
│ ┌───────────┬──────────────────┬─────────────────────┐ │
│ │ 항목 │ @SpringBootTest │ Slice Test │ │
│ ├───────────┼──────────────────┼─────────────────────┤ │
│ │ 로드 범위 │ 전체 Bean │ 특정 계층 Bean만 │ │
│ │ 속도 │ 느림 (5~30초) │ 빠름 (1~5초) │ │
│ │ 사용시점 │ 전체 통합 검증 │ 계층별 기능 검증 │ │
│ │ 설정 │ 간단 (다 로드) │ Mock 설정 필요 │ │
│ │ Bean 수 │ 전체 (수백 개) │ 최소 필요분 (수십) │ │
│ │ DB │ 필요할 수 있음 │ 내장 DB / 불필요 │ │
│ │ 현실성 │ 높음 (실제와 유사)│ 중간 (계층만 검증) │ │
│ └───────────┴──────────────────┴─────────────────────┘ │
│ │
│ 비유: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @SpringBootTest = 종합 건강검진 │ │
│ │ ├── 전신을 다 검사 │ │
│ │ ├── 시간 오래 걸림 (반나절) │ │
│ │ └── 문제를 빠짐없이 발견 │ │
│ │ │ │
│ │ Slice Test = 전문과 진료 │ │
│ │ ├── 눈만 검사, 치아만 검사 │ │
│ │ ├── 빠름 (30분) │ │
│ │ └── 해당 부위 문제를 정밀하게 발견 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.2 실전 선택 가이드
┌─────────────────────────────────────────────────────────────────┐
│ 실전 선택 가이드 │
│ │
│ "어떤 테스트를 써야 하지?" 결정 플로우: │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ 무엇을 테스트하고 싶은가? │ │
│ └──────────────┬───────────────────────────┘ │
│ │ │
│ ┌────────────┼────────────┬──────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ Controller Repository JSON 변환 전체 흐름 │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ @WebMvcTest @DataJpaTest @JsonTest @SpringBootTest │
│ │
│ 구체적인 상황별 가이드: │
│ │
│ ✅ @WebMvcTest 쓸 때: │
│ ├── Controller의 URL 매핑이 올바른지 │
│ ├── 요청 파라미터 검증이 작동하는지 │
│ ├── 응답 HTTP 상태 코드가 올바른지 │
│ └── 응답 JSON 구조가 올바른지 │
│ │
│ ✅ @DataJpaTest 쓸 때: │
│ ├── 커스텀 쿼리가 올바른 결과를 반환하는지 │
│ ├── 엔티티 간 관계(1:N, M:N)가 올바른지 │
│ ├── DB 제약조건(UNIQUE, NOT NULL)이 작동하는지 │
│ └── 페이징/정렬이 올바른지 │
│ │
│ ✅ @JsonTest 쓸 때: │
│ ├── 날짜 형식이 올바른지 │
│ ├── 커스텀 직렬화/역직렬화가 올바른지 │
│ └── null 처리가 올바른지 │
│ │
│ ✅ @SpringBootTest 쓸 때: │
│ ├── Controller → Service → Repository 전체 흐름 │
│ ├── 트랜잭션이 여러 서비스에 걸쳐 올바른지 │
│ ├── 보안 설정이 전체적으로 올바른지 │
│ └── 여러 API를 연속 호출하는 시나리오 │
│ │
└─────────────────────────────────────────────────────────────────┘
6. 실전에서 필수인 관련 개념들
6.1 TestContainers - 진짜 DB로 테스트하기
┌─────────────────────────────────────────────────────────────────┐
│ TestContainers - Docker로 진짜 DB 띄워서 테스트 │
│ │
│ 문제 상황: │
│ ├── @DataJpaTest는 기본으로 내장 DB(H2)를 사용 │
│ ├── 그런데 H2와 실제 DB(PostgreSQL, MySQL)는 다름! │
│ ├── H2에서는 통과하는데 실제 DB에서는 실패하는 경우 발생 │
│ └── 특히: JSON 타입, 배열, 윈도우 함수 등 │
│ │
│ TestContainers란? │
│ = Docker 컨테이너로 진짜 DB를 띄워서 테스트하는 도구 │
│ │
│ Docker란? (간단히) │
│ = 프로그램을 "컨테이너"라는 격리된 환경에서 실행하는 기술 │
│ = 비유: 일회용 가상 컴퓨터. 필요할 때 만들고, 쓰고, 버림 │
│ │
│ TestContainers의 동작: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. 테스트 시작 → Docker로 PostgreSQL 컨테이너 생성 │ │
│ │ 2. 진짜 PostgreSQL에서 테스트 실행 │ │
│ │ 3. 테스트 끝 → 컨테이너 자동 삭제 │ │
│ │ │ │
│ │ → 실제 환경과 100% 동일한 DB로 테스트! │ │
│ │ → H2와의 차이로 인한 버그를 사전에 발견! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Kotlin 예시: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @DataJpaTest │ │
│ │ @AutoConfigureTestDatabase(replace = Replace.NONE) │ │
│ │ @Testcontainers │ │
│ │ class UserRepositoryTest { │ │
│ │ │ │
│ │ companion object { │ │
│ │ @Container │ │
│ │ val postgres = PostgreSQLContainer( │ │
│ │ "postgres:15-alpine" │ │
│ │ ) │ │
│ │ │ │
│ │ @JvmStatic │ │
│ │ @DynamicPropertySource │ │
│ │ fun properties(registry: │ │
│ │ DynamicPropertyRegistry) { │ │
│ │ registry.add("spring.datasource.url", │ │
│ │ postgres::getJdbcUrl) │ │
│ │ registry.add("spring.datasource.username", │ │
│ │ postgres::getUsername) │ │
│ │ registry.add("spring.datasource.password", │ │
│ │ postgres::getPassword) │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ │ @Autowired │ │
│ │ lateinit var userRepository: UserRepository │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `PostgreSQL에서 사용자 검색`() { │ │
│ │ userRepository.save( │ │
│ │ User(name = "Kim", email = "kim@test.com") │ │
│ │ ) │ │
│ │ val found = userRepository.findByName("Kim") │ │
│ │ assertThat(found).isNotNull │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 코드 설명: │
│ ├── @Testcontainers: TestContainers 활성화 │
│ ├── @Container: Docker 컨테이너 정의 │
│ ├── PostgreSQLContainer: 진짜 PostgreSQL을 Docker로 실행 │
│ ├── @DynamicPropertySource: DB 접속 정보를 동적으로 설정 │
│ └── Replace.NONE: 내장 DB 대신 진짜 DB 사용 │
│ │
│ 장점: │
│ ├── 실제 DB와 동일한 환경에서 테스트 │
│ ├── CI/CD에서도 동일하게 실행 가능 │
│ └── "내 PC에서는 되는데..." 문제 방지 │
│ │
│ 단점: │
│ ├── Docker 설치 필요 │
│ ├── 내장 DB보다 느림 (컨테이너 시작 시간) │
│ └── CI 환경에 Docker가 있어야 함 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.2 @MockBean vs @SpyBean
┌─────────────────────────────────────────────────────────────────┐
│ @MockBean vs @SpyBean 차이 │
│ │
│ @MockBean = 완전한 가짜 (전부 연기하는 대역 배우) │
│ ├── 모든 메서드가 null 또는 기본값을 반환 │
│ ├── "이 메서드 호출되면 이거 반환해" 라고 설정 필수 │
│ └── 설정하지 않은 메서드 호출 시 → null/0/false 반환 │
│ │
│ @SpyBean = 진짜를 감싼 가짜 (본인이 연기하되 일부만 대역) │
│ ├── 기본적으로 진짜 로직이 실행됨 │
│ ├── 특정 메서드만 "이 메서드는 이거 반환해" 라고 덮어쓸 수 있음│
│ └── 나머지는 원래 코드 그대로 실행 │
│ │
│ 비유: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 영화 촬영에 비유: │ │
│ │ │ │
│ │ @MockBean = 대역 배우 (Stunt Double) │ │
│ │ ├── 모든 장면을 대역이 연기 │ │
│ │ ├── 감독: "이 장면에서 이렇게 연기해" │ │
│ │ └── 지시하지 않은 장면 → 가만히 서 있음 │ │
│ │ │ │
│ │ @SpyBean = 본인 + 일부 장면만 대역 │ │
│ │ ├── 대부분 본인이 직접 연기 │ │
│ │ ├── 위험한 액션 장면만 대역 │ │
│ │ └── 나머지는 본인의 진짜 연기 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 코드 예시: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // @MockBean: 완전한 가짜 │ │
│ │ @MockBean │ │
│ │ lateinit var userService: UserService │ │
│ │ // userService.getUser(1) → null (설정 안 하면) │ │
│ │ // given(userService.getUser(1)).willReturn(...) 필수! │ │
│ │ │ │
│ │ // @SpyBean: 진짜를 감싼 가짜 │ │
│ │ @SpyBean │ │
│ │ lateinit var userService: UserService │ │
│ │ // userService.getUser(1) → 진짜 로직 실행! │ │
│ │ // 특정 메서드만 덮어쓰기 가능: │ │
│ │ // doReturn(...).whenever(userService).sendEmail(any()) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 언제 무엇을 쓰나? │
│ ├── @MockBean: 의존성을 완전히 제거하고 싶을 때 │
│ └── @SpyBean: 대부분 진짜로 돌리되 일부만 가짜로 할 때 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.3 @Sql, @DirtiesContext
┌─────────────────────────────────────────────────────────────────┐
│ @Sql, @DirtiesContext 설명 │
│ │
│ @Sql: │
│ = "테스트 전에 이 SQL 파일을 실행해라" │
│ = 테스트에 필요한 데이터를 미리 준비 │
│ = 비유: 시험 전에 교실 책상 배치하는 것 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @Test │ │
│ │ @Sql("/test-data/users.sql") // 이 SQL을 먼저 실행 │ │
│ │ fun `사용자 목록 조회`() { │ │
│ │ val users = userRepository.findAll() │ │
│ │ assertThat(users).hasSize(3) │ │
│ │ } │ │
│ │ │ │
│ │ -- test-data/users.sql │ │
│ │ INSERT INTO users (name, email) │ │
│ │ VALUES ('Kim', 'kim@test.com'), │ │
│ │ ('Lee', 'lee@test.com'), │ │
│ │ ('Park', 'park@test.com'); │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ @DirtiesContext: │
│ = "이 테스트가 끝나면 ApplicationContext를 버리고 새로 만들어" │
│ = 테스트가 Spring 설정을 변경했을 때 사용 │
│ = 다음 테스트에 영향을 주지 않기 위함 │
│ = 비유: "내가 교실을 엉망으로 만들었으니, 다음 시간 전에 │
│ 교실을 완전히 새로 세팅해줘" │
│ │
│ ⚠️ 주의: @DirtiesContext는 느림! │
│ ├── ApplicationContext를 재생성하므로 시간이 오래 걸림 │
│ └── 꼭 필요한 경우에만 사용 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.4 테스트 격리 (Test Isolation)
┌─────────────────────────────────────────────────────────────────┐
│ 테스트 격리 (Test Isolation) │
│ │
│ 격리(Isolation)란? │
│ = 각 테스트가 서로 영향을 주지 않는 것 │
│ = 테스트 A의 결과가 테스트 B에 영향을 주면 안 됨 │
│ │
│ ❌ 격리가 안 된 경우: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 테스트 A: DB에 User("Kim") 저장 │ │
│ │ 테스트 B: DB에서 findAll() → 기대: 0개 │ │
│ │ │ │
│ │ 결과: 테스트 B에서 1개 나옴! (A가 저장한 Kim) │ │
│ │ → 테스트 순서에 따라 결과가 달라짐 = 불안정! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ✅ 격리가 된 경우: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 테스트 A: DB에 User("Kim") 저장 → 테스트 끝 → 롤백! │ │
│ │ 테스트 B: DB에서 findAll() → 기대: 0개 │ │
│ │ │ │
│ │ 결과: 테스트 B에서 0개 나옴! (A의 데이터가 사라짐) │ │
│ │ → 테스트 순서와 무관하게 항상 같은 결과 = 안정적! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Spring Boot에서 격리하는 방법: │
│ ├── @DataJpaTest → 자동 롤백 (@Transactional 적용) │
│ ├── @Sql → 테스트 전 데이터 초기화 │
│ ├── @DirtiesContext → ApplicationContext 재생성 │
│ └── @BeforeEach에서 직접 데이터 정리 │
│ │
│ 비유: 수학 시험 │
│ ├── 격리 O: 매 문제마다 새 계산 용지 제공 │
│ └── 격리 X: 이전 학생이 쓴 낙서가 남아있는 종이 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.5 BDD 스타일 테스트
┌─────────────────────────────────────────────────────────────────┐
│ BDD 스타일 테스트 (Given-When-Then) │
│ │
│ BDD = Behavior-Driven Development (행위 주도 개발) │
│ = "이 코드가 어떻게 행동하는지"를 중심으로 테스트 │
│ │
│ Given-When-Then 패턴: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Given (주어진 상황): │ │
│ │ = 테스트의 사전 조건. "이런 상황이 주어졌을 때" │ │
│ │ = 비유: "고객이 장바구니에 상품 3개를 담았을 때" │ │
│ │ │ │
│ │ When (행동): │ │
│ │ = 테스트할 행위. "이 행동을 하면" │ │
│ │ = 비유: "결제 버튼을 누르면" │ │
│ │ │ │
│ │ Then (결과): │ │
│ │ = 기대하는 결과. "이런 결과가 나와야 한다" │ │
│ │ = 비유: "주문이 생성되고, 재고가 3개 줄어야 한다" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ BDDMockito 사용 예시: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ import org.mockito.BDDMockito.given │ │
│ │ import org.mockito.BDDMockito.then │ │
│ │ │ │
│ │ @Test │ │
│ │ fun `사용자 생성 시 이메일 발송`() { │ │
│ │ // Given: 이런 상황이 주어졌을 때 │ │
│ │ val user = UserDto("Kim", "kim@test.com") │ │
│ │ given(userRepository.save(any())) │ │
│ │ .willReturn(User(1L, "Kim", "kim@test.com")) │ │
│ │ │ │
│ │ // When: 이 행동을 하면 │ │
│ │ userService.createUser(user) │ │
│ │ │ │
│ │ // Then: 이런 결과가 나와야 한다 │ │
│ │ then(emailService).should() │ │
│ │ .sendWelcomeEmail("kim@test.com") │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ given() = "이 상황에서" (BDDMockito) │
│ when() = "이 행동을 하면" (Kotlin에서는 when이 예약어라 │
│ 메서드 호출로 대체) │
│ then() = "이런 결과여야 한다" │
│ │
│ 왜 BDD 스타일을 쓰나? │
│ ├── 테스트가 읽기 쉬워짐 (자연어에 가까움) │
│ ├── 비개발자도 테스트 의도를 이해할 수 있음 │
│ └── "뭘 테스트하는지"가 명확함 │
│ │
└─────────────────────────────────────────────────────────────────┘
7. 실전 프로젝트 테스트 구성 예시
7.1 전체 테스트 구조
┌─────────────────────────────────────────────────────────────────┐
│ 실전 프로젝트의 테스트 디렉토리 구조 │
│ │
│ src/test/kotlin/com/example/myapp/ │
│ │ │
│ ├── unit/ ← 순수 단위 테스트 │
│ │ ├── service/ (Spring 없이 순수 Kotlin) │
│ │ │ ├── UserServiceTest.kt │
│ │ │ └── OrderServiceTest.kt │
│ │ └── domain/ │
│ │ ├── MoneyTest.kt │
│ │ └── OrderTest.kt │
│ │ │
│ ├── slice/ ← Slice 테스트 │
│ │ ├── web/ (@WebMvcTest 사용) │
│ │ │ ├── UserControllerTest.kt │
│ │ │ └── OrderControllerTest.kt │
│ │ ├── jpa/ (@DataJpaTest 사용) │
│ │ │ ├── UserRepositoryTest.kt │
│ │ │ └── OrderRepositoryTest.kt │
│ │ └── json/ (@JsonTest 사용) │
│ │ └── UserDtoJsonTest.kt │
│ │ │
│ └── integration/ ← 통합 테스트 │
│ ├── UserApiIntegrationTest.kt (@SpringBootTest) │
│ └── OrderFlowTest.kt (@SpringBootTest) │
│ │
│ 테스트 지원 파일: │
│ src/test/resources/ │
│ ├── application-test.yml ← 테스트 전용 설정 │
│ └── test-data/ ← @Sql용 SQL 파일 │
│ ├── users.sql │
│ └── orders.sql │
│ │
└─────────────────────────────────────────────────────────────────┘
7.2 테스트 비율 가이드
┌─────────────────────────────────────────────────────────────────┐
│ 테스트 비율 가이드 (권장) │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Unit Test: 70% ████████████████████████ │ │
│ │ (빠르고 많이) (빠름: ms 단위) │ │
│ │ │ │
│ │ Slice Test: 20% ███████ │ │
│ │ (계층별 검증) (보통: 1~5초) │ │
│ │ │ │
│ │ Integration Test: 8% ███ │ │
│ │ (전체 흐름) (느림: 5~30초) │ │
│ │ │ │
│ │ E2E Test: 2% █ │ │
│ │ (핵심 시나리오만) (매우 느림: 분 단위) │ │
│ │ │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ 예시: 테스트가 총 100개라면 │
│ ├── Unit Test: 70개 (Service 로직, 도메인 객체 등) │
│ ├── Slice Test: 20개 (Controller, Repository 등) │
│ ├── Integration Test: 8개 (주요 API 흐름) │
│ └── E2E Test: 2개 (로그인→주문→결제 같은 핵심 시나리오) │
│ │
│ 왜 이 비율인가? │
│ ├── Unit Test가 많으면: 빠르게 피드백, 빠르게 버그 발견 │
│ ├── E2E가 많으면: 느리고, 불안정하고, 유지보수 힘듦 │
│ └── Slice Test로 "실제적이면서 빠른" 균형을 잡음 │
│ │
│ ⚠️ 절대적인 규칙은 아님! │
│ ├── 프로젝트 특성에 따라 비율 조정 │
│ ├── CRUD 위주 → Slice Test 비율 높일 수 있음 │
│ └── 복잡한 비즈니스 로직 → Unit Test 비율 높일 수 있음 │
│ │
└─────────────────────────────────────────────────────────────────┘
7.3 실전 팁: ApplicationContext 캐싱
┌─────────────────────────────────────────────────────────────────┐
│ 실전 팁: ApplicationContext 캐싱으로 속도 향상 │
│ │
│ Spring Test는 ApplicationContext를 캐싱한다! │
│ = 같은 설정의 테스트들은 Context를 공유 │
│ = 첫 번째 테스트만 느리고, 이후 테스트는 빠름 │
│ │
│ ✅ Context가 캐싱되는 경우 (같은 Context 재사용): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ @WebMvcTest(UserController::class) │ │
│ │ class UserControllerTest1 { ... } │ │
│ │ │ │
│ │ @WebMvcTest(UserController::class) // 같은 설정! │ │
│ │ class UserControllerTest2 { ... } │ │
│ │ │ │
│ │ → 두 번째 테스트는 Context를 새로 만들지 않고 재사용 │ │
│ │ → 매우 빠름! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ❌ Context 캐싱을 깨뜨리는 것들: │
│ ├── @MockBean이 다른 경우 (다른 Bean을 Mock함) │
│ ├── @DirtiesContext 사용 (Context 강제 재생성) │
│ ├── 프로파일(@ActiveProfiles)이 다른 경우 │
│ └── 속성(@TestPropertySource)이 다른 경우 │
│ │
│ 팁: @MockBean을 가능한 통일하면 Context 캐싱 효율이 높아짐 │
│ │
└─────────────────────────────────────────────────────────────────┘
8. 자주 하는 실수와 해결법
┌─────────────────────────────────────────────────────────────────┐
│ 자주 하는 실수 Top 5 │
│ │
│ 실수 1: 모든 테스트에 @SpringBootTest 사용 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ❌ 잘못된 예: │ │
│ │ @SpringBootTest // 전체 로드 → 느림! │ │
│ │ class UserControllerTest { ... } │ │
│ │ │ │
│ │ ✅ 올바른 예: │ │
│ │ @WebMvcTest(UserController::class) // 필요한 것만! │ │
│ │ class UserControllerTest { ... } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실수 2: @MockBean 남발로 Context 캐싱 파괴 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ❌ 테스트마다 다른 @MockBean 조합 │ │
│ │ → 매번 새 Context 생성 → 느림 │ │
│ │ │ │
│ │ ✅ 공통 상위 클래스에 @MockBean 통일 │ │
│ │ abstract class BaseControllerTest { │ │
│ │ @MockBean lateinit var userService: UserService │ │
│ │ @MockBean lateinit var orderService: OrderService │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실수 3: 테스트 간 데이터 격리 안 함 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ❌ 테스트 A가 저장한 데이터가 테스트 B에 영향 │ │
│ │ → 실행 순서에 따라 결과가 달라짐 │ │
│ │ │ │
│ │ ✅ @DataJpaTest 사용 (자동 롤백) │ │
│ │ ✅ @BeforeEach에서 데이터 정리 │ │
│ │ ✅ @Sql로 데이터 초기화 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실수 4: 테스트에서 실제 외부 API 호출 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ❌ 테스트에서 진짜 카카오 API 호출 │ │
│ │ → 네트워크 끊기면 테스트 실패 │ │
│ │ → 비용 발생 │ │
│ │ │ │
│ │ ✅ @RestClientTest + MockRestServiceServer 사용 │ │
│ │ ✅ WireMock으로 외부 API 모킹 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실수 5: 테스트 이름이 모호함 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ❌ fun testGetUser() { ... } │ │
│ │ → 무엇을 검증하는지 모름 │ │
│ │ │ │
│ │ ✅ fun `존재하지 않는 사용자 조회 시 404 반환`() { ... } │ │
│ │ → 한글로 명확하게! (Kotlin 백틱 활용) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9. Stale Test (스테일 테스트) - 썩은 테스트
9.1 Stale Test란?
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test (스테일 테스트) │
│ │
│ Stale = "신선하지 않은", "오래되어 썩은" │
│ (빵이 stale 해졌다 = 빵이 딱딱해지고 상했다) │
│ │
│ Stale Test = "코드는 변했는데 테스트는 안 따라간 테스트" │
│ │
│ 비유: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 건강검진 비유: │ │
│ │ │ │
│ │ 10년 전 검진표: "시력 2.0, 건강함" │ │
│ │ 현재 실제 상태: 시력 0.3, 안경 필요 │ │
│ │ │ │
│ │ 옛날 검진표를 보고 "나는 건강해!" 라고 믿으면? │ │
│ │ → 위험! 현실을 반영하지 못하는 검사 = Stale Test │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 정의: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Stale Test = 코드가 변했지만 테스트는 업데이트되지 않아 │ │
│ │ 더 이상 의미 있는 검증을 하지 못하는 테스트 │ │
│ │ │ │
│ │ ● 통과는 하지만 실제로 아무것도 검증하지 않음 │ │
│ │ ● 실패해야 하는데 통과하여 거짓된 안전감을 줌 │ │
│ │ ● 코드 변경과 동기화되지 않은 "죽은" 테스트 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9.2 Stale Test가 생기는 과정
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test가 만들어지는 과정 │
│ │
│ 처음: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // 할인 로직: 10% 고정 할인 │ │
│ │ fun calculateDiscount(price: Long): Long { │ │
│ │ return price * 10 / 100 │ │
│ │ } │ │
│ │ │ │
│ │ // 테스트: 10% 할인 검증 │ │
│ │ @Test │ │
│ │ fun `10000원의 10% 할인은 1000원`() { │ │
│ │ assertEquals(1000, calculateDiscount(10000)) │ │
│ │ } │ │
│ │ → ✅ 코드와 테스트가 일치. 좋은 테스트! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 6개월 후: 요구사항이 바뀜 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // 할인 로직 변경: VIP 등급별 차등 할인 │ │
│ │ fun calculateDiscount(price: Long, grade: Grade): Long {│ │
│ │ return when (grade) { │ │
│ │ Grade.VIP -> price * 20 / 100 │ │
│ │ Grade.GOLD -> price * 15 / 100 │ │
│ │ Grade.NORMAL -> price * 10 / 100 │ │
│ │ } │ │
│ │ } │ │
│ │ │ │
│ │ // 테스트는 그대로... (아무도 안 고침) │ │
│ │ @Test │ │
│ │ fun `10000원의 10% 할인은 1000원`() { │ │
│ │ assertEquals(1000, │ │
│ │ calculateDiscount(10000, Grade.NORMAL)) │ │
│ │ } │ │
│ │ → ⚠️ 통과는 하지만 VIP/GOLD 할인은 검증 안 함! │ │
│ │ → 이것이 Stale Test! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 문제가 터지는 순간: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ VIP 할인 계산에 버그가 있어도 │ │
│ │ 테스트는 여전히 ✅ 통과 │ │
│ │ │ │
│ │ 개발자: "테스트 다 통과하니까 배포해도 되겠지!" │ │
│ │ 프로덕션: VIP 고객 할인이 안 됨 → 클레임 폭주 │ │
│ │ │ │
│ │ 원인: 테스트가 현재 코드를 제대로 검증하지 않았기 때문 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9.3 Stale Test의 유형
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test의 5가지 유형 │
│ │
│ 유형 1: Orphaned Test (고아 테스트) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 테스트 대상 코드가 삭제/이동되었는데 │ │
│ │ 테스트만 남아있는 경우 │ │
│ │ │ │
│ │ 예: UserService.findByEmail() 삭제됨 │ │
│ │ → 테스트는 Mock만 검증하므로 여전히 통과 │ │
│ │ → 실제로는 아무것도 테스트하지 않음 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 유형 2: Partial Coverage Test (부분 검증 테스트) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 코드에 새 기능/분기가 추가되었는데 │ │
│ │ 테스트가 업데이트되지 않은 경우 │ │
│ │ │ │
│ │ 예: if-else 3개 → 5개로 늘었는데 │ │
│ │ 테스트는 여전히 3개 분기만 검증 │ │
│ │ → 새 분기의 버그를 놓침 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 유형 3: Tautological Test (동어반복 테스트) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Mock이 반환하는 값을 그대로 검증하는 테스트 │ │
│ │ = 자기가 정한 답을 자기가 맞추는 시험 │ │
│ │ │ │
│ │ ❌ 나쁜 예: │ │
│ │ given(userService.findById(1)) │ │
│ │ .willReturn(User("Kim")) │ │
│ │ val result = userService.findById(1) │ │
│ │ assertEquals("Kim", result.name) // Mock 값 = 검증 값 │ │
│ │ → 항상 통과. 실제 로직 검증 안 함 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 유형 4: Flaky-Stale Test (불안정한 스테일 테스트) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 때때로 실패하는 테스트를 @Disabled 처리하고 방치한 경우 │ │
│ │ │ │
│ │ @Disabled("가끔 실패해서 임시 비활성화") │ │
│ │ @Test │ │
│ │ fun `동시성 테스트`() { ... } │ │
│ │ │ │
│ │ → "임시"라고 했지만 6개월째 비활성화 │ │
│ │ → 그 사이 관련 코드는 계속 변경됨 │ │
│ │ → 다시 활성화하면 컴파일조차 안 될 수 있음 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 유형 5: Copy-Paste Stale Test (복붙 스테일 테스트) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 기존 테스트를 복사해서 새 테스트를 만들었는데 │ │
│ │ assertion만 바꾸고 setup은 안 바꾼 경우 │ │
│ │ │ │
│ │ ❌ 테스트 이름: `관리자 권한으로 삭제 시 성공` │ │
│ │ 실제 setup: 일반 사용자로 로그인 │ │
│ │ → 관리자 권한 테스트인 척 하지만 일반 사용자 테스트 │ │
│ │ → 테스트 이름과 내용 불일치 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9.4 Stale Test가 위험한 이유
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test의 실제 피해 │
│ │
│ 1. 거짓된 안전감 (False Confidence) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ "테스트 100개 모두 통과! 배포 OK!" │ │
│ │ │ │
│ │ 실제 상황: │ │
│ │ ├── 유효한 테스트: 60개 (실제로 뭔가를 검증) │ │
│ │ ├── Stale Test: 30개 (항상 통과, 검증 안 함) │ │
│ │ └── Disabled: 10개 (실행조차 안 됨) │ │
│ │ │ │
│ │ → 실제 테스트 커버리지: 60%인데 100%로 착각 │ │
│ │ → "테스트가 있으니까 안전해" 라는 착각이 가장 위험 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 2. 코드 변경 공포 (Fear of Change) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Stale Test가 많아지면: │ │
│ │ ├── 코드 수정 → 어떤 테스트가 깨질지 예측 불가 │ │
│ │ ├── 테스트 수정 → 원래 의도를 모르니까 건드리기 무서움 │ │
│ │ └── 결과 → 아무도 리팩토링을 안 하게 됨 │ │
│ │ │ │
│ │ "테스트가 이상한데... 건드렸다가 더 망하면 어쩌지?" │ │
│ │ → 레거시 코드가 점점 더 레거시가 되는 악순환 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 3. CI/CD 파이프라인 낭비 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Stale Test도 CI에서 실행됨: │ │
│ │ ├── 실행 시간: 아무것도 검증 안 하면서 시간만 소비 │ │
│ │ ├── 리소스 낭비: 불필요한 Context 로딩, DB 연결 │ │
│ │ └── 비용 증가: 클라우드 CI는 실행 시간 = 비용 │ │
│ │ │ │
│ │ 100개 Stale Test × 2초 = 매 빌드마다 200초 낭비 │ │
│ │ 하루 50번 빌드 × 200초 = 하루 약 3시간 낭비 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9.5 Stale Test가 나오게 된 배경
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test 문제의 역사적 배경 │
│ │
│ 2000년대 초반: TDD와 테스트 문화의 확산 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Kent Beck의 "Test-Driven Development" (2002) 출간 │ │
│ │ → "테스트를 먼저 작성하라!" 라는 문화가 퍼짐 │ │
│ │ → 테스트 커버리지를 높이는 것이 좋다는 인식 │ │
│ │ → 회사들이 "커버리지 80% 이상" 같은 규칙을 만듦 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 2010년대: 테스트 부채의 발견 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 프로젝트가 3-5년 지나면서: │ │
│ │ ├── 테스트가 수천 개로 늘어남 │ │
│ │ ├── 원래 작성자는 퇴사함 │ │
│ │ ├── 비즈니스 로직은 수십 번 바뀜 │ │
│ │ └── 테스트는 처음 작성된 그대로 │ │
│ │ │ │
│ │ → "테스트 부채(Test Debt)" 개념 등장 │ │
│ │ → 코드 부채(Technical Debt)처럼 테스트도 부채가 쌓임 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 핵심 원인: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. 커버리지 숫자에 집착 │ │
│ │ → "커버리지 높이기" 위해 의미 없는 테스트 작성 │ │
│ │ │ │
│ │ 2. 테스트를 "한 번 쓰고 끝"으로 인식 │ │
│ │ → 코드 리뷰 때 테스트 변경은 검토 안 함 │ │
│ │ │ │
│ │ 3. 코드 변경과 테스트 변경의 분리 │ │
│ │ → 코드만 고치고 PR 올림 → 테스트 업데이트 누락 │ │
│ │ │ │
│ │ 4. @Disabled 남용 │ │
│ │ → 실패하면 원인 분석 대신 비활성화 │ │
│ │ → "나중에 고치겠다"는 나중은 오지 않음 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9.6 Stale Test 탐지 방법
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test 찾는 방법 │
│ │
│ 방법 1: Mutation Testing (돌연변이 테스트) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 원리: 코드를 일부러 망가뜨렸을 때 │ │
│ │ 테스트가 실패하는지 확인 │ │
│ │ │ │
│ │ 예: price * 10 / 100 → price * 10 * 100 (일부러 변경) │ │
│ │ 좋은 테스트: 즉시 실패 ✅ (변이를 "죽임") │ │
│ │ Stale 테스트: 여전히 통과 ❌ (변이가 "살아남음") │ │
│ │ │ │
│ │ 도구: PIT (Java/Kotlin), Stryker (JS/TS) │ │
│ │ │ │
│ │ // build.gradle.kts │ │
│ │ plugins { │ │
│ │ id("info.solidsoft.pitest") version "1.15.0" │ │
│ │ } │ │
│ │ pitest { │ │
│ │ targetClasses.set(listOf("com.example.*")) │ │
│ │ mutationThreshold.set(80) // 80% 변이 죽여야 통과 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 방법 2: 테스트 영향도 분석 (Test Impact Analysis) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 코드 변경 시 실패하지 않는 테스트 추적 │ │
│ │ │ │
│ │ 관련 코드를 10번 변경했는데 │ │
│ │ 한 번도 실패하지 않은 테스트 = Stale 의심 │ │
│ │ │ │
│ │ 도구: IntelliJ 커버리지 도구, JaCoCo 리포트 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 방법 3: 코드 리뷰 체크리스트 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PR 리뷰 때 확인할 것: │ │
│ │ ☐ 비즈니스 로직 변경 시 관련 테스트도 변경했는가? │ │
│ │ ☐ 새 분기/조건이 추가되었다면 테스트도 추가했는가? │ │
│ │ ☐ @Disabled 테스트가 있다면 이유와 기한이 있는가? │ │
│ │ ☐ Mock 설정이 실제 구현과 일치하는가? │ │
│ │ ☐ 테스트 이름이 실제 검증 내용과 일치하는가? │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 방법 4: 정기적 테스트 건강 점검 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 분기별 "테스트 스프린트" 운영: │ │
│ │ │ │
│ │ 1단계: @Disabled 테스트 목록 추출 │ │
│ │ 2단계: 6개월 이상 변경 없는 테스트 파일 추출 │ │
│ │ 3단계: Mutation Testing 실행 │ │
│ │ 4단계: 살아남은 변이(Stale 후보) 목록 작성 │ │
│ │ 5단계: 팀 리뷰 → 수정 or 삭제 결정 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9.7 Stale Test 예방과 해결
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test 예방 전략 │
│ │
│ 예방 1: 코드와 테스트를 항상 같이 변경 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 규칙: "프로덕션 코드 변경 = 테스트 코드도 변경" │ │
│ │ │ │
│ │ Git commit 메시지 예: │ │
│ │ ❌ "할인 로직 변경" │ │
│ │ ✅ "할인 로직을 등급별 차등 적용으로 변경 및 테스트 추가"│ │
│ │ │ │
│ │ PR 체크리스트에 추가: │ │
│ │ ☐ 프로덕션 코드 변경에 대응하는 테스트 변경 포함 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 예방 2: @Disabled에 만료일 달기 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // 만료일이 있는 @Disabled │ │
│ │ @Disabled("동시성 이슈 조사 중 - 2024-03-31까지") │ │
│ │ @Test │ │
│ │ fun `동시 주문 처리 테스트`() { ... } │ │
│ │ │ │
│ │ // CI에서 만료된 @Disabled를 찾는 스크립트 │ │
│ │ // expired-tests.sh │ │
│ │ grep -rn "@Disabled" src/test/ | │ │
│ │ grep -oP "\d{4}-\d{2}-\d{2}" | │ │
│ │ while read date; do │ │
│ │ if [[ "$date" < "$(date +%Y-%m-%d)" ]]; then │ │
│ │ echo "⚠️ 만료된 @Disabled 발견: $date" │ │
│ │ fi │ │
│ │ done │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 예방 3: Assertion 품질 높이기 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ❌ 약한 검증 (Stale 되기 쉬움): │ │
│ │ assertNotNull(result) // null 아니면 뭐든 통과 │ │
│ │ assertTrue(result.size > 0) // 1개든 100개든 통과 │ │
│ │ │ │
│ │ ✅ 강한 검증 (Stale 방지): │ │
│ │ assertEquals(3, result.size) │ │
│ │ assertEquals("Kim", result[0].name) │ │
│ │ assertThat(result).containsExactly( │ │
│ │ User("Kim"), User("Lee"), User("Park") │ │
│ │ ) │ │
│ │ │ │
│ │ → 구체적으로 검증할수록 코드 변경 시 테스트가 실패 │ │
│ │ → 실패 = 좋은 것! 변경을 감지한다는 의미 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 예방 4: Mutation Testing을 CI에 포함 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ // GitHub Actions 예시 │ │
│ │ // .github/workflows/mutation-test.yml │ │
│ │ name: Mutation Testing │ │
│ │ on: │ │
│ │ schedule: │ │
│ │ - cron: '0 2 * * 1' # 매주 월요일 새벽 2시 │ │
│ │ workflow_dispatch: │ │
│ │ jobs: │ │
│ │ pitest: │ │
│ │ runs-on: ubuntu-latest │ │
│ │ steps: │ │
│ │ - uses: actions/checkout@v4 │ │
│ │ - run: ./gradlew pitest │ │
│ │ - run: | │ │
│ │ SURVIVED=$(cat build/reports/pitest/index.html │ │
│ │ | grep -oP 'survived: \K\d+') │ │
│ │ if [ "$SURVIVED" -gt 10 ]; then │ │
│ │ echo "⚠️ $SURVIVED mutations survived!" │ │
│ │ exit 1 │ │
│ │ fi │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
9.8 Stale Test vs 관련 개념 비교
┌─────────────────────────────────────────────────────────────────┐
│ Stale Test와 헷갈리는 개념들 │
│ │
│ ┌──────────────┬────────────────────────────────────────┐ │
│ │ 용어 │ 설명 │ │
│ ├──────────────┼────────────────────────────────────────┤ │
│ │ Stale Test │ 코드와 동기화되지 않아 의미 없는 테스트 │ │
│ │ │ → 통과하지만 검증 안 함 │ │
│ ├──────────────┼────────────────────────────────────────┤ │
│ │ Flaky Test │ 같은 코드인데 실행할 때마다 결과 다름 │ │
│ │ │ → 때로 통과, 때로 실패 (비결정적) │ │
│ │ │ 원인: 타이밍, 순서 의존, 외부 자원 │ │
│ ├──────────────┼────────────────────────────────────────┤ │
│ │ Fragile Test │ 관련 없는 코드 변경에도 깨지는 테스트 │ │
│ │ (깨지기 쉬운)│ → 구현 세부사항에 너무 강하게 결합 │ │
│ │ │ 원인: 내부 구현 검증, 순서 의존 │ │
│ ├──────────────┼────────────────────────────────────────┤ │
│ │ Dead Test │ 아예 실행되지 않는 테스트 │ │
│ │ (죽은 테스트)│ → @Disabled, @Ignore, 주석 처리 │ │
│ │ │ Stale Test의 극단적 형태 │ │
│ └──────────────┴────────────────────────────────────────┘ │
│ │
│ 관계도: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Stale Test (썩은) │ │
│ │ ├── 심화 → Dead Test (죽은, @Disabled) │ │
│ │ └── 원인 → 코드 변경 미반영 │ │
│ │ │ │
│ │ Flaky Test (불안정) │ │
│ │ ├── 방치 → Stale Test로 전이 (@Disabled 처리 시) │ │
│ │ └── 원인 → 비결정적 요소 (시간, 네트워크, 순서) │ │
│ │ │ │
│ │ Fragile Test (깨지기 쉬운) │ │
│ │ ├── 반대 → Stale과 정반대 문제 │ │
│ │ │ (Stale: 안 깨짐 / Fragile: 너무 잘 깨짐) │ │
│ │ └── 원인 → 구현에 과도하게 결합 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
10. 정리
┌─────────────────────────────────────────────────────────────────┐
│ 전체 핵심 정리 │
│ │
│ 테스트란? │
│ = 내 코드가 올바르게 동작하는지 자동으로 확인하는 코드 │
│ = 수동 확인 대신 자동 검사 → 빠르고 정확하게 반복 가능 │
│ │
│ 테스트 피라미드: │
│ ├── Unit Test (70%): 함수 하나를 고립 테스트. 가장 빠름 │
│ ├── Slice Test (20%): 특정 계층만 잘라서 테스트. 빠르면서 실제적│
│ ├── Integration Test (8%): 전체 연결해서 테스트. 느리지만 현실적│
│ └── E2E Test (2%): 사용자 관점 전체 테스트. 가장 느림 │
│ │
│ Slice Test = "애플리케이션의 특정 계층만 잘라서 테스트" │
│ ├── 2016년 Spring Boot 1.4에서 도입 │
│ ├── @SpringBootTest의 "전부 로드 → 느림" 문제를 해결 │
│ └── 필요한 Bean만 로드 → 빠르면서도 실제적인 테스트 │
│ │
│ 주요 Slice Test 어노테이션: │
│ ├── @WebMvcTest: Controller 테스트 (MockMvc 사용) │
│ ├── @DataJpaTest: Repository 테스트 (내장 DB 사용) │
│ ├── @JsonTest: JSON 직렬화/역직렬화 테스트 │
│ ├── @WebFluxTest: WebFlux Controller 테스트 │
│ └── @RestClientTest: 외부 API 클라이언트 테스트 │
│ │
│ 핵심 도구: │
│ ├── @MockBean: 가짜 객체로 의존성 대체 │
│ ├── @SpyBean: 진짜 객체를 감싸서 일부만 가짜로 │
│ ├── TestContainers: Docker로 진짜 DB 띄워서 테스트 │
│ └── BDDMockito: Given-When-Then 스타일 테스트 │
│ │
│ 실전 원칙: │
│ ├── 테스트 격리: 각 테스트는 독립적이어야 함 │
│ ├── 적절한 레벨 선택: Controller → @WebMvcTest │
│ │ Repository → @DataJpaTest │
│ │ 전체 흐름 → @SpringBootTest │
│ ├── Context 캐싱 활용: @MockBean을 통일해서 속도 향상 │
│ └── 피라미드 지키기: Unit 많이, E2E 적게 │
│ │
│ Stale Test (썩은 테스트): │
│ = 코드는 변했는데 테스트가 안 따라간 "유통기한 지난 테스트" │
│ ├── 거짓된 안전감: 통과하지만 실제로 검증 안 함 │
│ ├── 예방: 코드 변경 = 테스트 변경, 강한 assertion 작성 │
│ └── 탐지: Mutation Testing (PIT), 정기 테스트 건강 점검 │
│ │
│ 한 줄 요약: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ "전부 다 테스트하지 말고, 필요한 부분만 잘라서(Slice) │ │
│ │ 빠르고 정확하게 테스트하자!" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
관련 키워드
Slice Test, @WebMvcTest, @DataJpaTest, @JsonTest, @WebFluxTest, @RestClientTest, @SpringBootTest, Test Pyramid, Unit Test, Integration Test, E2E Test, @MockBean, @SpyBean, MockMvc, TestContainers, ApplicationContext, Auto-Configuration, Bean, BDD, Given-When-Then, Test Isolation, Stale Test, Mutation Testing, Flaky Test, Fragile Test, Dead Test, Test Debt, PIT, False Confidence, Test Impact Analysis