1. 테스트의 기본 개념
학습 목표
- 왜 테스트를 작성하는가?
- 수동 테스트와 자동 테스트의 차이는 무엇인가?
- 테스트는 비용 대비 가치가 있는가?
- 리팩토링과 테스트는 어떤 관계인가?
- 테스트 종류에는 무엇이 있는가?
- 좋은 테스트는 어떤 테스트인가?
1) 왜 테스트를 작성하는가?
기능을 구현한 후 코드가 정상적으로 동작하는지 검증하는 과정은 필수적이다.
초기에는 보통 다음과 같이 main 메서드를 이용해 콘솔에 값을 출력하는 방식으로 검증을 시작한다.
// 검증 대상 클래스
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
// main 메서드를 통한 검증
public static void main(String[] args) {
Calculator calculator = new Calculator();
System.out.println(calculator.add(1, 2)); // 출력 결과: 3
}
단순한 기능 하나라면 이 방식으로도 충분하다.
하지만 실제 서비스에서 관리해야 하는 메서드가 100개, 1000개로 늘어난다면 이야기가 달라진다.
- 회원가입, 로그인, 비밀번호 변경
- 게시글 등록, 수정, 삭제
- 댓글 등록, 삭제 등
기능이 추가되거나 수정될 때마다 매번 애플리케이션을 직접 실행하고, 웹 브라우저나 API 도구를 통해 값을 입력하며 눈으로 확인해야 한다. 이것이 바로 수동 테스트다. 수동 테스트는 수정 사항이 생길 때마다 관련된 모든 기능을 사람이 다시 확인해야 하므로 비효율적이다. 자동 테스트를 작성하면 이 문제를 간단하게 해결할 수 있다.
@Test
void add() {
Calculator calculator = new Calculator();
assertEquals(3, calculator.add(1, 2));
}
자동 테스트 코드를 작성해 두면 IDE의 실행 버튼 한 번이나 빌드 명령어 한 줄로 수많은 기능을 몇 초 만에 검증할 수 있다.
즉, 테스트는 기능이 정상 동작하는지 자동으로 검증하기 위한 최소한의 안전장치이다.
2) 수동 테스트 vs 자동 테스트
수동 테스트와 자동 테스트의 특징을 비교하면 다음과 같다.
| 항목 | 수동 테스트 | 자동 테스트 |
| 실행 주체 | 사람이 직접 수행 | 작성된 프로그램이 자동 수행 |
| 속도 | 매우 느림 | 매우 빠름 |
| 반복성 | 낮음 (매번 비용 발생) | 높음 (언제든 재실행 가능) |
| 신뢰성 | 낮음 (휴먼 에러 존재) | 높음 (항상 동일한 규칙으로 검증) |
| 유지보수 | 시스템이 커질수록 검증 불가 | 주기적인 리팩토링으로 관리 가능 |
예를 들어, 회원가입 기능을 수정했을 때 수행해야 하는 과정은 다음과 같이 대비된다.
- 수동 테스트: 서버 실행 ➔ 웹 페이지 접속 ➔ 회원가입 폼 입력 및 요청 ➔ DB 데이터 적재 확인 ➔ 결과 화면 확인 (이 과정을 매번 반복해야 한다.)
- 자동 테스트: Run Test 버튼 클릭 (단 몇 초 만에 모든 검증이 끝난다.)
3) 테스트의 비용과 효과
테스트를 처음 접하는 개발자들이 가장 많이 하는 오해가 있다. "테스트 코드를 작성할 시간에 기능 하나를 더 만드는 게 이득 아닌가? 시간이 아깝다"라는 생각이다. 하지만 실제 소프트웨어 개발 생명 주기에서는 정반대의 현상이 일어난다.
// 서비스 로직의 회원가입 메서드
public void join(Member member)
만약 이 회원가입 서비스 로직을 수정한 후, 기존에 잘 작동하던 다른 기능에 사이드 이펙트(Side Effect)가 발생해 코드가 깨졌다고 가정해 본다.
- 테스트 코드가 없는 경우: 어디서 문제가 터졌는지 모르기 때문에 로그를 심고, 서버를 다시 띄우고, 직접 값을 입력해 보며 원인을 찾는 데 3시간 이상을 허비할 수 있다.
- 테스트 코드가 있는 경우: 코드를 수정하자마자 테스트를 돌려보면 실패한 위치와 원인을 3초 만에 찾아낼 수 있다.
테스트 작성 비용은 현재 발생하지만, 테스트의 효과는 프로젝트가 커지고 유지보수 기간이 길어질수록 기하급수적으로 증가한다.
따라서 테스트는 초기 비용을 투자해 미래의 거대한 리스크와 비용을 줄이는 행위다.
4) 리팩토링과 테스트
코드를 깔끔하게 다듬는 리팩토링 과정에서 테스트 코드는 절대적인 역할을 한다.
// 리팩토링 전 코드
public int add(int a, int b) {
return a + b;
}
이 코드에 대한 테스트가 이미 존재하므로 안심하고 리팩토링을 시작할 수 있다.
// 리팩토링 후 코드 (내부 구현 변형)
public int add(int a, int b) {
int result = a;
result += b;
return result;
}
내부 구현은 바뀌었지만 외부로 드러나는 기능은 동일하다. 리팩토링 후 테스트를 실행하여 PASS가 뜨는 것을 확인하면, 코드를 변경해도 기존 기능이 망가지지 않았음을 확신할 수 있다. 만약 테스트 코드가 없다면 코드가 정상 동작하는지 확신할 수 없기 때문에 불안감에 휩싸이게 되고, 결국 리팩토링 자체를 기피하게 된다. 테스트는 안심하고 코드를 수정할 수 있게 만드는 안전망(Safety Net)이자, 과감한 리팩토링을 가능하게 하는 원동력이다.
5) 테스트 종류
테스트는 검증하려는 범위와 목적에 따라 크게 4가지로 나뉜다.
이를 직관적으로 이해하기 위해 테스트 피라미드(Test Pyramid) 구조를 활용한다.

- Unit Test (단위 테스트)
- 가장 작은 단위(주로 하나의 클래스나 객체의 책임)를 독립적으로 검증한다.외부 의존성(DB, 네트워크, 파일 시스템 등)을 제거하거나 Mock으로 대체하므로 실행 속도가 매우 빠르다.
- 주로 비즈니스 로직, 조건 분기, 예외 처리 등을 검증한다.
- 예: `Calculator.add()` 메서드가 연산을 정확히 수행하는지 확인한다.
- Integration Test (통합 테스트)
- 여러 컴포넌트가 올바르게 연동되어 상호작용하는지 검증한다.
- 구조: Service ➔ Repository ➔ DB
- 회원 저장 요청 시 실제 DB에 정상적으로 저장되는지 확인한다.주로 JPA 매핑, 트랜잭션, 컴포넌트 간 협력 등을 검증한다.
- 실제 DB, Spring Context, 외부 시스템 등이 포함될 수 있으므로 단위 테스트보다 무겁고 실행 속도가 느리다.
- Acceptance Test (인수 테스트, 시스템 테스트)
- 사용자 관점(비즈니스 요구사항)에서 시스템이 올바르게 작동하는지 검증한다.
- 예: "회원가입 요청을 보냈을 때 실제로 회원 계정이 올바르게 생성되는가?"를 API 수준에서 확인한다.
- 주로 API 수준에서 검증하며, 내부 구현보다는 요구사항이 만족되는지에 초점을 둔다.
- 요구사항 명세서의 조건을 만족하는지 판단하는 기준이 된다.
- E2E Test (End-to-End 테스트)
- 시스템의 시작부터 끝까지 전체 흐름을 테스트한다.
- 구조: 브라우저(UI) ➔ Controller ➔ Service ➔ DB
- 사용자가 회원가입 화면에서 정보를 입력하고 가입 버튼을 눌었을 때, 실제로 회원이 생성되고 로그인까지 가능한지 확인한다.반면 환경 구축 비용이 크고 실행 속도가 가장 느리며 유지보수 비용도 높다.
- 실제 사용자 환경과 가장 유사한 테스트이며, 가장 높은 신뢰도를 제공한다.
1) 좋은 테스트의 조건: FIRST 원칙
좋은 테스트 코드를 작성하기 위해서는 FIRST 원칙을 반드시 준수해야 한다.
- F - Fast (빠르게): 테스트는 실행 속도가 빨라야 한다. 하나의 테스트를 돌리는 데 30분이 걸린다면 개발자는 테스트를 자주 실행하지 않게 된다. 언제든 버튼을 누르면 5초 내로 결과를 볼 수 있어야 한다.
- I - Independent (독립적으로): 각 테스트는 서로 격리되어 독립적으로 실행되어야 한다. `testA()`가 성공해야만 `testB()`가 성공할 수 있는 구조처럼 테스트 간에 순서나 상태를 의존해서는 안 된다.
- R - Repeatable (반복 가능하게): 테스트는 어떤 환경에서든, 언제 실행해도 항상 동일한 결과를 내야 한다. 네트워크 상태나 현재 시간에 따라 성공과 실패가 번복되는 테스트는 신뢰할 수 없다.
- S - Self-Validating (자가 검증하는): 테스트 스스로 성공과 실패를 판단할 수 있어야 한다. 눈으로 결과를 확인하기 위해 `System.out.println()`을 쓰는 것은 나쁜 방식이다. 반드시 `assertEquals()`나 `assertThat()` 같은 단언문(Assertion)을 사용하여 기계가 직접 판단하도록 해야 한다.
- T - Timely (적시에): 테스트 코드는 검증하려는 기능 구현 직전 혹은 직후에 적시에 작성해야 한다. 모든 기능을 다 개발하고 몇 달 뒤에 테스트를 작성하려고 하면 구조적인 한계에 부딪혀 테스트 코드 작성이 불가능해질 수 있다.
2. JUnit 기본 사용법
학습 목표
- JUnit5 테스트 작성 및 실행
- 테스트 생명주기(`@BeforeEach`, `@AfterEach` 등) 이해 및 활용
- `@DisplayName`을 통한 테스트 이름 관리
- `@RepeatedTest`를 이용한 반복 테스트 작성
- `@ParameterizedTest`와 다양한 Source를 활용한 파라미터 테스트 작성
- `@Nested`와 `@Tag`를 이용한 테스트 그룹화 및 분류
1) JUnit이란?
JUnit은 자바 생태계에서 가장 널리 사용되는 표준 테스트 프레임워크다. 현재 주로 사용되는 버전은 JUnit5이고, Junit6가 등장했다. 두 버전의 차이는 다음에 자세히 살펴보자. JUnit 프레임워크는 개발자가 작성한 테스트 코드를 인식하여 다음과 같은 일련의 과정을 자동으로 처리한다.
- 테스트 코드 실행 ➔ 결과 데이터 수집 ➔ 성공/실패 시각적 표시 ➔ 테스트 리포트 생성
개발자는 프레임워크가 제공하는 규칙에 따라 오직 "검증 코드"를 작성하는 데만 집중하면 된다.
2) 첫 번째 테스트 작성
테스트 대상이 되는 간단한 계산기 클래스다.
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}
이 클래스를 검증하는 JUnit5 테스트 코드는 다음과 같이 작성한다.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void add() {
// 1. 준비 (Given)
Calculator calculator = new Calculator();
// 2. 실행 (When)
int result = calculator.add(1, 2);
// 3. 검증 (Then)
assertEquals(3, result);
}
}
- `@Test` 애너테이션: JUnit에게 "이 메서드는 독립적으로 실행 가능한 테스트 메서드다"라고 알려주는 역할을 한다.
이 애너테이션이 붙지 않은 메서드는 테스트 대상에서 제외되어 실행되지 않는다.
3) 테스트 실행 구조와 생명주기
JUnit은 테스트 간의 독립성을 보장하기 위해, 각 테스트 메서드를 실행할 때마다 테스트 클래스의 인스턴스를 매번 새로 생성한다.
기본적인 실행 순서는 다음과 같다.
- [테스트 1] 인스턴스 생성 ➔ `@BeforeEach` ➔ `@Test (test1)` ➔ `@AfterEach`
[테스트 2] 인스턴스 생성 ➔ `@BeforeEach` ➔ `@Test (test2)` ➔ `@AfterEach`
`@BeforeEach`와 `@AfterEach`
- `@BeforeEach`: 각 테스트 메서드가 실행되기 직전에 매번 실행된다.
주로 테스트에 필요한 객체를 초기화하거나 공통 데이터를 준비하는 용도로 사용한다. - `@AfterEach`: 각 테스트 메서드가 종료된 직후에 매번 실행된다.
테스트 과정에서 사용한 파일 삭제, DB 초기화, 임시 리소스 정리 등에 사용한다.
// 나쁜 예: 매 테스트마다 객체를 중복 생성한다.
@Test
void add() {
Calculator calculator = new Calculator(); // 중복
// ...
}
@Test
void subtract() {
Calculator calculator = new Calculator(); // 중복
// ...
}
// 좋은 예: @BeforeEach를 통해 중복을 제거하고 필드로 관리한다.
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
System.out.println("초기화");
calculator = new Calculator();
}
@Test
void test1() {
System.out.println("테스트 1 실행");
}
@AfterEach
void tearDown() {
System.out.println("정리");
}
}
`@BeforeAll`과 `@AfterAll`
- `@BeforeAll` / `@AfterAll`: 테스트 클래스 전체에서 딱 한 번씩만 실행된다.
- 모든 테스트 메서드가 공유해야 하는 무거운 리소스를 다룰 때 사용한다.
(예: 데이터베이스 커넥션 풀 생성, 외부 통합 서버 시작, 대용량 정적 데이터 로딩 등) - 주의할 점은 JUnit의 기본 수명주기에서 이 메서드들은 반드시 `static` 메서드로 작성해야 한다.
@BeforeAll
static void beforeAll() {
System.out.println("전체 테스트 시작 - DB 연결");
}
@AfterAll
static void afterAll() {
System.out.println("전체 테스트 종료 - DB 연결 해제");
}
4) 테스트 이름 관리 및 네이밍 전략
`@DisplayName` 애너테이션
기본적으로 테스트 결과 창에는 메서드 이름이 표시된다. 하지만 영어로 작성된 메서드명이나 짧은 이름은 테스트의 의도와 비즈니스 요구사항을 명확히 전달하기 어렵다. 이때 `@DisplayName`을 사용하면 콘솔이나 IDE에 사람이 읽기 좋은 형태로 테스트 이름을 표기할 수 있다.
@Test
@DisplayName("1과 2를 더하면 3이 반환된다")
void add() {
// ...
}
- 실행 결과: ✓ 1과 2를 더하면 3이 반환된다
실무 추천 네이밍 전략
실무에서는 주로 메서드 네임과 `@DisplayName`을 조합하여 사용한다.
- 메서드명: `기능명_기대조건` 형태로 담백하게 작성한다. (예: `join_success`, `join_fail_duplicateEmail`)
- `@DisplayName`: 해당 테스트가 검증하는 비즈니스 요구사항을 한글로 명확하게 작성한다. (예: `@DisplayName("회원 가입 성공")`, `@DisplayName("중복된 이메일로 가입하면 예외가 발생한다")`)
5) 반복 테스트 (`@RepeatedTest`)
동일한 테스트를 설정한 횟수만큼 반복해서 실행하고자 할 때 사용한다.
@RepeatedTest(10)
@DisplayName("동시성 이슈 검증을 위해 10번 반복한다")
void repeatTest() {
// 10번 연속 실행되는 테스트 로직
}
- 주요 활용 처: 무작위(Random) 값을 이용한 테스트 결과 검증, 멀티스레드 환경에서의 동시성 이슈(Race Condition) 확인 등
6) 파라미터 테스트 (`@ParameterizedTest`)
하나의 테스트 메서드에 여러 가지 다른 입력값과 기대값을 번갈아 넣으며 검증해야 할 때 사용한다.
각각의 테스트 메서드를 여러 개 만들지 않고 코드 중복을 획기적으로 줄일 수 있어 JUnit의 꽃이라 불린다.
7) `@ValueSource`
단일 타입의 단순한 배열 데이터를 차례대로 주입할 때 사용한다.
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void isPositive(int value) {
assertTrue(value > 0);
}
8) `@CsvSource` (실무에서 가장 많이 사용)
입력값과 그에 따른 결과값을 세트로 묶어서 테스트하고 싶을 때 사용한다. 콤마(`,`)를 기준으로 데이터를 구분한다.
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"2, 3, 5",
"10, 20, 30"
})
@DisplayName("두 수를 더한 값이 올바른지 검증한다")
void addWithCsv(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
9) `@MethodSource`
주입해야 할 데이터 구조가 복잡하거나 복잡한 객체(Member, Order 등)를 생성하여 전달해야 할 때 활용한다.
복잡한 데이터를 반환하는 static 메서드를 지정한다.
@ParameterizedTest
@MethodSource("provideStringsForTest")
void isBlankTest(String input, boolean expected) {
assertEquals(expected, input.isBlank());
}
static Stream<Arguments> provideStringsForTest() {
return Stream.of(
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("JUnit", false)
);
}
10) `@EnumSource`
특정 Enum 클래스에 정의된 모든 상수를 순회하며 테스트 데이터를 자동으로 주입하고 싶을 때 사용한다.
enum Grade { BASIC, VIP, VVIP }
@ParameterizedTest
@EnumSource(Grade.class)
void gradeTest(Grade grade) {
assertNotNull(grade);
}
11) 테스트 그룹화 및 분류
`@Nested`
테스트 클래스 내부에 계층 구조를 만들어 관련 있는 테스트들을 논리적으로 묶어준다.
가독성을 높이고 BDD(Behavior-Driven Development) 스타일로 명세서를 작성하듯 구조화할 수 있다.
class MemberServiceTest {
@Nested
@DisplayName("회원가입 기능은")
class Join {
@Test
@DisplayName("올바른 정보 입력 시 성공한다")
void success() {}
@Test
@DisplayName("이메일이 중복되면 가입에 실패한다")
void duplicateEmail() {}
}
}
- 계층형 트리 결과 구조: `MemberServiceTest` ➔ `Join` ➔ `success` / `duplicateEmail`
`@Tag`
테스트에 태그를 부여하여 특정 카테고리로 분류한다.
@Test
@Tag("unit")
void fastTest() { ... }
@Test
@Tag("integration")
void slowTest() { ... }
- 활용 방법: CI/CD 환경이나 빌드 도구(Gradle, Maven) 설정에서 "빠른 unit 태그만 실행하고 무거운 integration 태그는 야간 빌드 시에만 실행"하도록 필터링할 수 있다.
💡 기능별 중요도 및 핵심 요약
| 중요도 | 애너테이션 | 주요 목적 |
| 필수 (Must) |
`@Test`, `@BeforeEach`, `@AfterEach`, `@DisplayName`, `@ParameterizedTest`, `@CsvSource` |
일상적인 단위 테스트 및 중복 데이터 제거를 위해 무조건 마스터해야 하는 핵심 기능 |
| 자주 사용 (Should) |
`@Nested`, `@Tag`, `@MethodSource` | 테스트 가독성을 높이고,객체 기반 데이터 주입 및 CI/CD 빌드 최적화를 위해 필수적인 기능 |
| 가끔 사용 (Optional) |
`@RepeatedTest`, `@EnumSource`, `@BeforeAll`, `@AfterAll` |
특수한 상황(동시성 테스트, Enum 전체 검증, 공통 무거운 리소스 초기화)에 유용하게 사용하는 기능 |
3. Assertion 익히기
학습 목표
- JUnit 주요 단언문(`assertEquals`, `assertThrows`, `assertAll` 등) 활용
- AssertJ의 assertThat을 활용한 기본 검증
- 문자열, 컬렉션(Collection), 객체 데이터 검증
- AssertJ를 활용한 가독성 높은 예외(`Exception`) 검증
- JUnit Assertion과 AssertJ의 차이점 이해
3.1 Assertion이란?
Assertion(단언, 검증)은 테스트 코드에서 "결과가 기대한 값과 같은지 확인하는 검증문"을 의미한다. 테스트의 품질과 신뢰성을 결정하는 가장 핵심적인 파트다. 테스트 코드는 주로 Given-When-Then 구조를 따르며, Assertion은 마지막 Then(검증) 단계에서 수행된다.
@Test
@DisplayName("1과 2를 더하면 3이 반환된다")
void add() {
// 1. Given (준비): 테스트를 하기 위한 준비 과정
int a = 1;
int b = 2;
// 2. When (실행): 검증하려는 로직 실행
int result = a + b;
// 3. Then (검증): 결과가 기대치와 일치하는지 단언(Assertion)
assertEquals(3, result);
}
1) JUnit 주요 기본 단언문
JUnit5의 `org.junit.jupiter.api.Assertions` 클래스가 제공하는 대표적인 메서드들이다.
`assertEquals()` & `assertNotEquals()`
- `assertEquals(expected, actual)`: 기대값(`expected`)과 실제값(`actual`)이 같은지 검증한다.
회원 수, 주문 금액, 계산 결과, HTTP 상태 코드 등을 검증할 때 가장 많이 사용된다. - 실패 시 콘솔에 `Expected : 10`, `Actual : 20`과 같이 명확한 차이를 출력한다.
assertEquals(10, result); // result가 10이면 패스
// 반대 조건 검증
int price = 10000;
int discounted = price - 1000;
assertNotEquals(price, discounted); // 두 값이 다르면 패스
`assertTrue()` & `assertFalse()`
- 조건식이나 결과값이 `true` 또는 `false`인지 검증한다.
assertTrue(age >= 20);
assertTrue(member.isActive()); // 실무 활용: 활성화 여부 확인
assertFalse(member.isDeleted()); // 실무 활용: 삭제 여부 확인
`assertNull()` & `assertNotNull()`
- 객체가 null인지 혹은 null이 아닌지 검증한다.
Member member = repository.findById(999L);
assertNull(member); // 조회가 안 되어 null이어야 패스
Member saved = repository.save(new Member("Kim"));
assertNotNull(saved.getId()); // DB에 저장되어 ID(PK)가 생성되었는지 확인
JUnit의 한계 극복: `assertAll()`
단일 검증 메서드를 여러 개 나열하면, 첫 번째 검증이 실패했을 때 뒤에 있는 검증문들은 실행조차 되지 않고 테스트가 종료된다는 문제가 있다.
// 나쁜 예: getName()이 실패하면 getAge()와 getGrade()는 검증 기회조차 잃는다.
assertEquals("Kim", member.getName());
assertEquals(20, member.getAge());
assertEquals("VIP", member.getGrade());
이 문제를 해결하기 위해 `assertAll()`을 사용한다. 여러 검증을 람다식으로 묶어 동시에 실행하며, 실패하더라도 모든 검증을 끝까지 수행한 뒤 어떤 검증들이 실패했는지 한 번에 모아서 보여준다.
// 좋은 예: 실무에서 DTO나 엔티티 필드를 한 번에 검증할 때 유용하다.
assertAll(
() -> assertEquals("Kim", member.getName()),
() -> assertEquals(20, member.getAge()),
() -> assertEquals("VIP", member.getGrade())
);
3.2 JUnit 예외 검증
실무에서는 정상 흐름만큼이나 예외가 발생하는 예외 흐름(비즈니스 예외, 잘못된 인자 값 등) 검증이 매우 중요하다.
1) `assertThrows()`
특정 로직을 실행했을 때 기대하는 예외가 반드시 발생해야 성공하는 검증문이다.
예외가 발생하지 않거나 다른 예외가 발생하면 테스트는 실패한다.
// 검증 대상 메서드
public void divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("0으로 나눌 수 없습니다.");
}
}
@Test
@DisplayName("0으로 나누면 IllegalArgumentException이 발생한다")
void divideByZero() {
assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
}
발생한 예외 객체를 반환받아 예외 메시지까지 상세히 검증할 수도 있다.
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
assertEquals("0으로 나눌 수 없습니다.", exception.getMessage());
2) `assertDoesNotThrow()`
반대로 예외가 발생하지 않고 무사히 성공해야 함을 명시적으로 검증할 때 사용한다. (예: 회원가입 성공, 결제 성공 등)
assertDoesNotThrow(() -> calculator.divide(10, 2));
3)실무 표준: AssertJ 기반 검증
JUnit의 단언문은 `assertEquals(expected, actual)` 형태로 동작하여, 간혹 파라미터 위치를 헷갈리거나 코드가 복잡해질 때 가독성이 떨어지는 단점이 있다. 실무에서는 가독성과 편의성이 훨씬 뛰어난 AssertJ 프레임워크의 `assertThat()`을 표준으로 사용한다. 스프링 부트 프로젝트 생성 시 `spring-boot-starter-test`에 기본 포함되어 있다.
// JUnit5: 영어 문장 구조와 맞지 않아 읽기 어색하다.
assertEquals(10, result);
assertTrue(name.contains("Kim"));
// AssertJ: "result가 10과 같은지 단언한다" 순방향으로 부드럽게 읽힌다.
assertThat(result).isEqualTo(10);
assertThat(name).contains("Kim");
AssertJ를 사용할 때는 다음 패키지를 `static import`하여 사용한다.
import static org.assertj.core.api.Assertions.*;
4) 문자열 검증
다양한 체이닝 메서드를 통해 직관적인 문자열 검증이 가능하다.
String name = "Kim Min Su";
assertThat(name).isEqualTo("Kim Min Su");
assertThat(name).contains("Min"); // 포함 여부
assertThat(name).startsWith("Kim"); // 시작 문자열
assertThat(name).endsWith("Su"); // 끝 문자열
5) 컬렉션(Collection) 검증
리스트나 셋 같은 자료구조를 검증할 때 매우 유용하며, 실무에서 빈번하게 쓰인다.
List<String> names = List.of("Kim", "Lee", "Park");
assertThat(names).contains("Kim"); // 포함 여부
assertThat(names).contains("Kim", "Lee"); // 여러 원소 포함 여부
assertThat(names).hasSize(3); // 크기 검증
assertThat(names).isNotEmpty(); // 비어있지 않은지 검증
6) 객체 검증 (extracting)
객체의 특정 필드값들만 쏙 뽑아내어(추출) 간결하게 검증할 수 있다.
// 일반적인 방식 (필드가 많아질수록 코드가 늘어남)
assertThat(member.getName()).isEqualTo("Kim");
assertThat(member.getAge()).isEqualTo(20);
// 실무 추천 방식 (extracting 활용)
assertThat(member)
.extracting("name", "age")
.containsExactly("Kim", 20); // 순서와 값이 정확히 일치하는지 확인
7) AssertJ 예외 검증
JUnit의 assertThrows보다 더 자연스럽게 흐르는(Fluent) 문법으로 예외를 검증할 수 있다.
assertThatThrownBy(() -> calculator.divide(10, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("0으로 나눌 수 없습니다.");
3.3💡 실무에서 가장 많이 쓰는 Assertion TOP 10
AssertJ와 JUnit을 통틀어 다음 10가지 조합만 익혀도 실무 테스트 코드 검증의 80% 이상을 쉽게 커버할 수 있다.
- `assertThat(actual).isEqualTo(expected)` (값 비교)
- `assertThat(actual).isNotNull()` (Non-Null 검증)
- `assertThat(actual).isNull()` (Null 검증)
- `assertThat(actual).isTrue()` / `isFalse()` (논리값 검증)
- `assertThat(actual).contains(...)` (문자열/컬렉션 포함 여부)
- `assertThat(actual).hasSize(expected)` (컬렉션 크기)
- `assertThat(actual).extracting(...)` (객체 필드 추출)
- `assertAll(...)` (JUnit - 그룹 다중 검증)
- `assertThrows(...)` (JUnit - 기본 예외 검증)
- `assertThatThrownBy(...)` (AssertJ - 체이닝 예외 검증)
4. 단위 테스트 설계
학습 목표
- Given-When-Then 구조를 적용한 명확한 테스트 코드 작성
- 비즈니스 요구사항에 맞는 유의미한 테스트 케이스 도출
- 버그가 가장 많이 발생하는 경계값 분석(Boundary Value Analysis) 활용
- 견고한 시스템을 위한 예외 상황 테스트 작성
- 테스트 픽스처(Fixture)를 활용한 테스트 코드 리팩토링 및 데이터 관리
4.1 왜 단위 테스트 설계를 배워야 할까?
단순히 `@Test` 애너테이션을 붙이고 단언문(Assertion)을 작성하는 방법 자체는 누구나 금방 배운다. 그러나 정작 실무에서 개발자들이 마주하는 가장 큰 난관은 "대체 무엇을, 어디까지 테스트해야 하는가?"를 모른다는 점이다. 예를 들어 다음과 같은 나눗셈 메서드가 존재한다.
public int divide(int a, int b) {
return a / b;
}
테스트 숙련도가 낮은 초보자는 보통 가장 평범한 정상 케이스 하나만 작성하고 만족한다.
- `10 / 2 = 5` (성공)
하지만 진정하게 견고한 코드를 만들기 위해서는 다음과 같은 다양한 상황과 데이터의 조합을 도출해낼 수 있어야 한다.
- `10 / 1` (나누는 수가 1인 경우)
- `0 / 10` (나누어지는 수가 0인 경우)
- `-10 / 2` 또는 `10 / -2` (음수가 포함된 경우)
- `10 / 0` (0으로 나누는 예외 상황)
즉, 테스트 코드를 타이핑하는 기술보다 구현부의 맹점을 찌르는 '테스트 케이스를 도출하는 설계 능력'이 훨씬 더 중요하다.
4.2 가독성을 높이는 Given-When-Then 구조
테스트 코드를 작성할 때 전 세계적으로 가장 널리 쓰이는 표준 패턴이다.
코드를 읽는 사람이 테스트의 의도와 흐름을 단 1초 만에 파악할 수 있도록 돕는다.
- Given (준비): 테스트를 수행하기 위한 전제 조건과 입력 데이터를 준비하는 단계다.
- When (실행): 실제 검증하고자 하는 핵심 로직이나 메서드를 호출하는 단계다.
- Then (검증): 실행 결과를 받고, 기대했던 값과 일치하는지 단언(Assertion)하는 단계다.
@Test
@DisplayName("지정한 두 수로 나눗셈 연산을 수행한다")
void divide_success() {
// given (준비)
int a = 10;
int b = 2;
// when (실행)
int result = calculator.divide(a, b);
// then (검증)
assertThat(result).isEqualTo(5);
}
주석으로 세 단계를 명확히 분리해 주는 것만으로도 무분별하게 섞여 있는 코드보다 가독성이 기하급수적으로 좋아진다.
4.3 핵심 설계 기법 1: 경계값 분석 (Boundary Value Analysis)
실무에서 버그가 가장 많이 발생하는 지점은 다름 아닌 조건문의 경계다.
따라서 테스트 케이스를 구성할 때는 경계의 중심과 그 주변 값을 반드시 묶어서 테스트해야 한다.
// 성인 여부 판별 메서드
public boolean isAdult(int age) {
return age >= 20;
}
평범한 개발자는 `isAdult(25)` 등 안전한 값 위주로만 테스트한다. 그러나 훌륭한 개발자는 경계선인 `19, 20, 21`을 집중적으로 검증한다. 만약 구현하는 과정에서 개발자의 실수로 `age >= 20` 대신 `age > 20`으로 부등호를 잘못 입력했다면, 25나 19 테스트는 정상 작동하는 것처럼 보이지만 20 테스트에서 즉시 버그가 포착된다.
💡 실무 경계값 도출 패턴
비즈니스 요구사항에 조건이 보인다면 무조건 경계를 찾고 아래 값들을 도출한다.
- "금액이 10,000원 이상일 때 할인이 적용된다" ➔ 9,999원, 10,000원, 10,001원 검증
- "최대 10개까지만 담을 수 있다" ➔ 9개, 10개, 11개 검증
4.2 핵심 설계 기법 2: 예외 상황 테스트
실무 소프트웨어 장애의 대부분은 정상 흐름이 아니라 예측하지 못한 예외 상황을 처리하지 못했을 때 발생한다. 통상적으로 잘 설계된 테스트 스위트는 정상 케이스가 30%, 예외 케이스가 70%의 비중을 차지한다.
// 회원가입 이메일 검증 로직이 있을 때
public void join(String email)
이 메서드를 견고하게 만들기 위해 도출해야 하는 예외 케이스는 다음과 같다.
- 이메일에 null이 들어오는 경우
- 빈 문자열("")이나 공백(" ")이 들어오는 경우
- 이메일 형식이 아닌 문자열("abc", "invalid")이 들어오는 경우
@Test
@DisplayName("회원가입 시 이메일에 null이 입력되면 예외가 발생한다")
void email_is_null() {
assertThatThrownBy(() -> service.join(null))
.isInstanceOf(IllegalArgumentException.class);
}
4.3 테스트 코드 리팩토링과 데이터 관리 (Fixture)
테스트 코드 역시 운영 코드와 마찬가지로 유지보수해야 하는 소중한 자산이다.
테스트 코드에 중복이 넘쳐나면 운영 코드가 조금만 변경되어도 수많은 테스트 코드를 깨뜨리게 된다.
Fixture 기법을 통한 데이터 중복 제거
동일한 객체 생성 로직이 여러 테스트 메서드 내부에서 무분별하게 반복된다면 이를 별도의 생성 메서드로 추출해야 한다.
테스트 환경을 위해 고정된 데이터를 준비하는 이 객체들을 픽스처(Fixture)라 부른다.
// AS-IS: 나쁜 예 (모든 테스트 메서드 내부마다 중복 생성)
@Test
void test1() {
Member member = new Member("김철수", 20, "kim@gmail.com"); // 중복
// ...
}
@Test
void test2() {
Member member = new Member("김철수", 20, "kim@gmail.com"); // 중복
// ...
}
// TO-BE: 좋은 예 (픽스처 생성 메서드 추출)
@Test
void test1() {
Member member = createMember();
// ...
}
private Member createMember() {
return new Member("김철수", 20, "kim@gmail.com");
}
규모가 큰 실무 프로젝트에서는 아예 테스트 데이터 생성을 전담하는 별도의 클래스를 구축하여 활용한다.
- `MemberFixture.create()`
- `OrderFixture.create()`
4.4 좋은 테스트의 3대 특징
- 단일 책임 원칙 (한 가지만 검증): 하나의 테스트 메서드는 오직 하나의 목적만 검증해야 한다.
회원가입 ➔ 로그인 ➔ 주문 흐름을 한 메서드에서 검증하는 것은 단위 테스트가 아니다. 무조건 분리한다. - 요구사항을 담은 테스트 명세: 테스트 메서드의 이름이나 `@DisplayName`은 개발자용 기술 명칭(`test1`)이 아닌 비즈니스 요구사항(`중복된_이메일이면_예외가_발생한다`)이어야 한다.
- 철저한 독립성: 어떤 테스트가 먼저 실행되고 나중에 실행되든 상관없이 항상 성공해야 한다.
이전 테스트가 DB에 남긴 데이터 때문에 다음 테스트가 깨지는 구조는 절대로 금물이다.
🎯 실무 중심의 회원가입 요구사항 도출 예시
실무에서 요구사항 명세서를 받으면 다음과 같이 기계적으로 테스트 케이스를 쪼개어 도출할 수 있어야 한다.
- 요구사항: 이메일은 필수이며 중복될 수 없다. 비밀번호는 최소 8자 이상이어야 한다.
- 도출된 단위 테스트 리스트:
- [ ] 회원가입 성공 케이스 (정상 데이터)
- [ ] 가입 실패: 이미 존재하는 이메일 (중복 예외)
- [ ] 가입 실패: 이메일 값이 null이거나 빈 문자열 (필수 값 예외)
- [ ] 가입 실패: 비밀번호 7자 입력 (경계값 이하 예외)
- [ ] 가입 성공: 비밀번호 8자 입력 (경계값 일치 성공)
- [ ] 가입 성공: 비밀번호 9자 입력 (경계값 이상 성공)
5. Test Double (테스트 더블)
학습 목표
- Test Double의 개념과 단위 테스트에서의 필요성 설명
- 5가지 종류(Dummy, Fake, Stub, Spy, Mock)의 차이점 이해
- 상태 검증(Stub)과 행위 검증(Mock)의 차이점 설명
- 서비스 계층 테스트 시 리포지토리(Repository)를 Mocking하는 이유 파악
5.1 Test Double이란?
영화 촬영에서 위험한 액션 장면을 주연 배우 대신 수행하는 스턴트 배우를 'Stunt Double'이라 부른다. 소프트웨어 테스트에서도 이와 유사하게 실제 객체를 대신하여 테스트 환경을 보조하는 대체 객체를 가리켜 Test Double(테스트 더블)이라 부른다.
왜 Test Double이 필요한가?
다음과 같은 회원 서비스 클래스가 존재한다고 가정해 본다.
public class MemberService {
private final MemberRepository repository;
public MemberService(MemberRepository repository) {
this.repository = repository;
}
public void join(Member member) {
repository.save(member);
}
}
MemberService라는 하나의 독립된 단위(Unit)만 깔끔하게 테스트하고 싶지만, 구조상 MemberRepository를 거쳐 실제 데이터베이스(DB)까지 네트워크가 연결되어야만 테스트가 가능하다. 이 경우 다음과 같은 치명적인 한계가 발생한다.
- DB 환경 구축, 네트워크 상태, 트랜잭션 처리 등 외부 요인으로 인해 테스트가 실패할 수 있다.
- 실제 데이터베이스와 통신하므로 테스트 실행 속도가 현저히 느려진다.
이때 실제 MemberRepository 대신 `repository.save(member)`라는 행위만 흉내 내는 가짜 객체(Test Double)를 주입하면 외부 의존성을 완벽히 끊어낼 수 있다. 테스트 더블 핵심 요약은, 실제 객체를 사용하면 환경 제약이 생기고 속도가 느려진다. 가짜 객체를 사용하면 빠르고, 단순하며, 독립적인 테스트가 가능하다. 즉, 단위 테스트에서 외부 의존성을 격리하는 핵심 수단이 바로 Test Double이다.
5-2. Test Double의 5가지 종류
Test Double은 역할과 구현 방식에 따라 크게 5가지로 분류된다. 이는 기술 면접에서도 매우 자주 출제되는 단골 주제다.
1) Dummy (더미)
가장 단순한 형태의 더미 객체다. 인스턴스화된 객체가 필요할 뿐, 해당 객체의 기능이나 메서드 호출 결과는 테스트 흐름에 아무런 영향을 주지 않을 때 사용한다.
- 특징: 전달만 될 뿐, 내부 로직에서 실제로 쓰이지 않는다. (자리 채우기 용도)
// 의존 주입을 위해 Logger 객체가 필요하지만, 테스트 자체에서는 로깅 여부를 검증하지 않을 때
public void sendEmail(String email, Logger logger) { ... }
// 테스트 코드
Logger dummyLogger = new Logger(); // 오직 파라미터를 맞추기 위한 용도
service.sendEmail("test@gmail.com", dummyLogger);
2) Fake (페이크)
실제 의존하는 객체의 복잡한 로직을 단순화하여 자체적인 구현체를 직접 작성한 가짜 객체다.
- 특징: 실제 내부 로직(동작)은 존재하지만, 운영 환경에서 사용하기에는 적합하지 않은 경량화된 형태다.
- 실무 예시: 실제 MySQL 데이터베이스에 접근하는 대신, 메모리 내부의 `HashMap`을 활용해 데이터를 적재하고 반환하는 `InMemoryRepository`를 직접 구현하는 방식이다.
class FakeMemberRepository implements MemberRepository {
private final Map<Long, Member> store = new HashMap<>();
@Override
public Member save(Member member) {
store.put(member.getId(), member);
return member;
}
}
3) Stub (스텁)
테스트를 진행할 수 있도록 미리 준비된 고정된 결과를 반환하는 객체다.
- 특징: 호출되면 어떤 인자가 들어오든 간에 항상 정해진 성공 혹은 실패 값을 리턴한다.
주로 시스템의 결과값을 검증하는 상태 검증(State Verification)에 사용된다.
class StubMemberRepository implements MemberRepository {
@Override
public Member findById(Long id) {
return new Member("kim"); // 어떤 ID를 조회해도 무조건 "kim"이라는 고정 객체 반환
}
}
4) Mock (모크)
의존 객체의 행위를 기록하고, 특정 메서드가 실제로 호출되었는지, 몇 번 호출되었는지 여부를 검증하기 위해 사용하는 가짜 객체다.
- 특징: 메서드의 호출 여부, 호출 횟수, 전달된 파라미터가 유효한지 등을 확인하는 행위 검증(Behavior Verification)에 초점을 맞춘다.
// 호출 결과값 자체보다는, 회원가입 시 repository.save()가 "실제로 호출되었는가?"를 기계적으로 추적한다.
verify(repository).save(member);
5) Spy (스파이)
실제 객체를 기본적으로 사용하되, 해당 객체의 호출 기록을 마치 스파이처럼 감시하고 기록하는 더블이다.
- 특징: 실제 객체의 동작은 그대로 유지하면서, 필요에 따라 특정 메서드만 목킹(Mocking)하거나 호출 횟수를 기록/검증할 수 있다. 실무에서는 Mock보다 사용 빈도가 다소 낮다.
List<String> list = new ArrayList<>();
List<String> spyList = spy(list); // 실제 ArrayList를 감싸는 스파이 생성
spyList.add("A"); // 실제 리스트에 "A"가 정상 저장됨
verify(spyList).add("A"); // 동시에 add("A")가 호출되었는지 행위 검증도 가능
5.3 핵심 비교: Stub vs Mock
Test Double 개념 중 가장 중요하며 면접에서 가장 높은 확률로 출제되는 대목이다. 두 개념은 목적성에서 명확한 차이를 보인다.
| 항목 | Stub (스텁) | Mock (모크) |
| 핵심 목적 | 상태 검증 (State Verification) | 행위 검증 (Behavior Verification) |
| 주요 역할 | 테스트에 필요한 결과/데이터를 미리 준비하여 반환 | 대상 메서드가 정상적으로 호출되었는지 프로세스 검증 |
| 작동 메커니즘 | `thenReturn()`, `willDoNothing()` 등으로 결과 세팅 | `verify()`를 활용하여 실행 흐름 추적 |
💡 실무에서의 반전: Mockito 프레임워크의 등장
자바 실무에서는 Mockito라는 강력한 테스트 프레임워크를 사용한다. Mockito 환경에서는 `@Mock` 애너테이션 하나로 가짜 객체를 만든 뒤, 이를 Stub과 Mock 용도로 혼용하여 사용한다.
// 1. Stub 역할로 사용 (결과 준비 -> 상태 검증용)
when(repository.findById(1L)).thenReturn(member);
// 2. Mock 역할로 사용 (호출 검증 -> 행위 검증용)
verify(repository).save(member);
따라서 실무에서는 개념적 성격(상태 검증이냐 행위 검증이냐)만 명확히 인지한 채, Stub 기능과 Mock 기능을 적재적소에 조합하여 테스트 코드를 작성한다.
1) Service 테스트에서 Repository를 가짜로 대체하는 이유
public void join(Member member) {
validateDuplicateMember(member); // 비즈니스 로직
repository.save(member); // 데이터 저장 행위
}
MemberServiceTest에서 우리가 궁극적으로 검증해야 하는 대상은 회원가입 절차에 녹아있는 비즈니스 로직(중복 검증 등)과 저장 명령이 올바르게 내려졌는가이다. 실제 데이터베이스에 저장되는 메커니즘 자체는 `MemberRepositoryTest`라는 별도의 영속성 계층 테스트에서 전담하여 검증해야 한다. "서비스 테스트는 오직 서비스의 가치만 검증한다"는 단위 테스트의 단일 책임 원칙을 지키기 위해 의존 관계에 있는 Repository를 가짜로 대체하는 것이다.
2) 핵심 정리
- Dummy: 사용 안 함. 자리만 채움.
- Fake: 단순하게 구현되어 동작함. (In-Memory 구조)
- Stub: 미리 정한 고정값 반환. (상태 검증)
- Mock: 호출 여부 및 횟수 검증. (행위 검증)
- Spy: 실제 객체를 기반으로 부분 감시 및 목킹.
5장까지 학습하며 가짜 객체의 이론적 개념을 마스터했다면, 다음 6장 Mockito 프레임워크 파트는 매우 직관적이고 수월하게 다가올 것이다. Mockito는 개념의 연장선일 뿐, "Mock과 Stub을 코드로 아주 쉽게 만들어주는 편리한 도구"에 불과하기 때문이다. 6장부터 본격적인 스프링 서비스 계층 테스트의 진수가 시작된다.
6. Mockito (모키토)
학습 목표
- `@Mock`과 `@InjectMocks`를 활용한 Mock 객체 생성 및 의존성 주입
- `when().thenReturn()` 및 `thenThrow()`를 이용한 Stub 설정
- `verify()`와 검증 메서드(`times`, `never`)를 활용한 행위 검증
- `ArgumentCaptor`를 이용해 메서드에 전달된 인자(Parameter) 검증
- 가독성을 높여주는 BDDMockito 스타일의 테스트 코드 작성
6.1 Mockito란?
Mockito는 자바에서 가짜 객체(Mock Object)를 아주 쉽게 생성하고, 관리하며, 검증할 수 있도록 도와주는 가장 표준적인 테스트 라이브러리다. 5장에서 배운 테스트 더블(Test Double) 개념을 실제 코드로 구현해 주는 강력한 도구다. 단위 테스트의 핵심은 "테스트 대상 계층만 독립적으로 검증하는 것"이다. MemberService를 테스트할 때 실제 DB나 영속성 계층(MemberRepository)까지 엮이지 않도록, 외부 의존성을 Mockito를 통해 가짜(Mock)로 대체한다.
1) Mock 객체 생성과 의존성 주입
과거에는 `mock(MemberRepository.class)`과 같이 메서드를 직접 호출하여 Mock 객체를 만들었으나,
현재 실무에서는 애너테이션을 활용한 선언적 방식을 표준으로 사용한다.
`@Mock`과 `@InjectMocks`
- `@ExtendWith(MockitoExtension.class)`: JUnit5 환경에서 Mockito 기능을 활성화하기 위해 테스트 클래스 상단에 반드시 선언해야 한다.
- `@Mock`: 가짜 객체(Mock)를 생성한다.
- `@InjectMocks`: `@Mock`이 붙은 가짜 객체들을 테스트 대상 클래스(Service)의 생성자나 필드에 자동으로 주입(Injection)해 준다.
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@Mock
private MemberRepository repository; // 가짜 객체 생성
@InjectMocks
private MemberService service; // repository가 자동으로 주입된 서비스 객체 생성
@Test
@DisplayName("회원가입 시 리포지토리의 save 메서드가 호출된다")
void join_success() {
Member member = new Member("kim");
// when
service.join(member);
// then (행위 검증)
verify(repository).save(member);
}
}
6.2 Stub 설정: 언제(when), 무엇을(then) 반환할 것인가
가짜 객체는 내부 구현이 없기 때문에 특정 메서드가 호출되었을 때 어떤 값을 반환할지 개발자가 직접 정해줘야 한다.
이를 Stub(스텁) 설정이라 한다.
1) 값 반환 설정 (thenReturn)
특정 인자가 주입되며 메서드가 호출될 때의 반환값을 고정한다.
// 1L 파라미터로 findById가 호출되면 무조건 지정한 member 객체를 반환하도록 설정
when(repository.findById(1L)).thenReturn(member);
// 테스트 검증
Member result = repository.findById(1L);
assertThat(result).isEqualTo(member);
2) 예외 발생 설정 (thenThrow)
비즈니스 예외 상황(데이터 없음, 권한 없음 등)을 테스트하기 위해 의도적으로 예외를 던지도록 설정한다.
// 1L 조회 시 RuntimeException을 던지도록 설정
when(repository.findById(1L)).thenThrow(new RuntimeException("존재하지 않는 회원"));
// 테스트 검증
assertThatThrownBy(() -> repository.findById(1L))
.isInstanceOf(RuntimeException.class);
3) 범용 인자 처리 (any())
특정 값이 아닌, "어떤 값이 들어오더라도" 동일한 결과를 보장하고 싶을 때 `any()` 아규먼트 매처(Argument Matcher)를 사용한다.
// 어떤 Long 타입 ID가 들어와도 무조건 member를 반환한다.
when(repository.findById(any(Long.class))).thenReturn(member);
4) 행위 검증 (verify)
행위 검증은 테스트 대상 로직을 실행한 후, 내부적으로 의존 객체의 특정 메서드가 "실제로 호출되었는가?"를 확인하는 기법이다.
상태값을 반환하지 않는 `void` 메서드를 검증할 때 매우 요긴하게 쓰인다.
// 단순히 호출 여부만 검증
verify(repository).save(member);
호출 횟수 세부 검증
- `times(n)`: 정확히 `n`번 호출되었는지 검증한다.
- `never()`: 단 한 번도 호출되지 않았음을 검증한다.
예외가 발생하여 로직이 중단되었을 때, 후속 메서드가 실행되지 않았음을 증명할 때 주로 쓰인다. - `atLeast(n)`: 적어도 `n`번 이상 호출되었는지 검증한다.
// save()가 정확히 2번 호출되었는지 확인
verify(repository, times(2)).save(member);
// 예외가 터졌으므로 delete()는 절대 호출되면 안 됨을 확인
verify(repository, never()).delete(any());
6.3 실무 필수 기법: ArgumentCaptor (인자 캡처)
메서드가 호출된 사실을 넘어, "그 메서드가 호출될 때 실제로 전달된 파라미터 내부에 어떤 값이 들어있었는지" 상세히 뜯어보고 싶을 때 사용한다. 주로 서비스 내부에서 가공된 엔티티나 DTO를 검증할 때 필수적으로 쓰인다.
// 1. 캡처할 클래스 타입 지정하여 캡처러 생성
ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);
// 2. 검증 단계에서 실제 메서드 파라미터 자리에 capture() 주입
verify(repository).save(captor.capture());
// 3. 캡처된 객체 꺼내기
Member savedMember = captor.getValue();
// 4. 캡처된 객체의 내부 필드 상세 검증
assertThat(savedMember.getName()).isEqualTo("kim");
6.4 실무 선호 스타일: BDDMockito
Mockito의 기존 `when().thenReturn()` 방식은 가독성이 훌륭하지만, 한 가지 직관적이지 않은 부분이 존재한다. 바로 Given(준비) 단계에 `when()`이라는 키워드가 들어간다는 점이다. 이러한 어색함을 지우고 Given-When-Then 구조와 단어의 짝을 완벽히 맞추기 위해 Mockito는 BDDMockito라는 래퍼(Wrapper) 클래스를 제공한다. 문법적 기능은 완벽히 동일하며, 이름만 직관적으로 변환된다.
// AS-IS: 기존 Mockito 스타일 (Given 단계에 when이 들어감)
when(repository.findById(1L)).thenReturn(member);
verify(repository).save(member);
// TO-BE: BDDMockito 스타일 (단어의 흐름이 일치함)
given(repository.findById(1L)).willReturn(member);
then(repository).should().save(member);
BDDMockito 전체 적용 예시
@Test
@DisplayName("BDD 스타일로 회원가입 프로세스를 검증한다")
void join_bdd_style() {
// given
Member member = new Member("kim");
given(repository.save(any(Member.class))).willReturn(member);
// when
service.join(member);
// then
then(repository).should(times(1)).save(member);
}
⚠️ Mockito 사용 시 주의점 (Anti-Pattern)
- 과도한 `verify()` 남발 금지: 테스트 대상 메서드 내부에서 일어나는 모든 사소한 줄 단위의 호출을 다 `verify()`로 묶는 것은 나쁜 습관이다. 이는 테스트 코드를 구현부와 너무 강하게 결합시켜, 내부 로직을 조금만 리팩토링해도 테스트가 대거 깨지는 원인이 된다. 비즈니스적으로 정말 중요한 행위만 선별하여 검증한다.
- 무분별한 Stubbing 자제: 사용하지 않는 메서드나 불필요한 파라미터까지 수십 줄씩 `when()`으로 정의해 두면 테스트 코드의 가독성이 급격히 떨어진다. 현재 테스트 케이스를 통과시키는 데 꼭 필요한 최소한의 뼈대만 목킹한다.
💡 실무에서 가장 많이 쓰는 Mockito 기능 TOP 5
- `@Mock` (가짜 객체 선언)
- `@InjectMocks` (가짜 의존성 자동 주입)
- `given().willReturn()` (BDD 스타일 결과값 조작)
- `then().should()` 또는 `verify()` (메서드 호출 여부 확인)
- `ArgumentCaptor` (메서드로 넘어간 파라미터 내부 정밀 검증)
'🍃SpringBoot' 카테고리의 다른 글
| JPA [1/4] : JPA 시작과 영속성 컨텍스트 (0) | 2026.06.23 |
|---|---|
| JUnit [2/2]: Service, Repository, Controller, 동시성 테스트 (0) | 2026.06.22 |
| 카카오 로그인 구현(실습): Spring Boot + React 소셜 로그인 구현 및 JWT 인증 연동(Google, Github 포함) (0) | 2026.06.19 |
| Java Time API: JWT와 Audit으로 배우는 실전 시간 정책 (0) | 2026.06.16 |
| 카카오 로그인 구현(이론): OAuth 2.0부터 JWT 인증 설계(Google, Github) (0) | 2026.06.12 |
