테스트 코드 — JUnit 5와 Mockito로 자바 테스트 작성하기
코드가 정상 동작한다는 걸 어떻게 보장할 수 있을까?
System.out.println으로 눈 확인하는 건 클래스가 수십 개로 늘어나면 한계가 온다. 자동화된 테스트가 있으면 코드를 변경할 때마다 수백 개의 시나리오를 즉시 검증할 수 있다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
1. 테스트를 왜 작성하나
"돌아가는데 테스트까지 써야 해요?"라는 질문을 한 번쯤은 해봤을 거예요. 테스트 코드를 작성하는 이유는 크게 세 가지입니다.
버그 방지
코드를 변경할 때마다 손으로 확인하기엔 경우의 수가 너무 많아요. 자동화된 테스트가 있으면 npm test(혹은 ./gradlew test) 한 번에 수백 개의 시나리오를 검증할 수 있습니다.
리팩토링 안전망
"이 메서드 이름 바꿔도 괜찮을까?" — 테스트가 통과하면 괜찮습니다. 테스트는 코드를 고칠 때 ** 깨진 부분을 즉시 알려주는 안전망** 역할을 해요.
살아 있는 문서
잘 작성된 테스트 코드는 "이 클래스를 어떻게 쓰라는 거지?"에 대한 가장 정확한 예제입니다. 주석이나 README보다 신뢰도가 높아요. 왜냐하면 ** 테스트는 거짓말을 하면 빨간불이 켜지니까요.**
2. JUnit 5 기본 — @Test와 Assertions
JUnit 5는 자바 테스트의 사실상 표준입니다. JUnit Platform + JUnit Jupiter + JUnit Vintage 세 모듈로 구성되지만, 대부분의 경우 Jupiter(테스트 작성 API)만 알면 돼요.
의존성 추가 (Gradle)
// build.gradle
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}
test {
useJUnitPlatform() // JUnit 5 플랫폼 사용
}
첫 번째 테스트
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
void 더하기_테스트() {
// given - 준비
Calculator calc = new Calculator();
// when - 실행
int result = calc.add(2, 3);
// then - 검증
assertEquals(5, result);
}
}
@Test를 붙이면 JUnit이 해당 메서드를 테스트로 인식합니다.- 메서드 이름은 한글로 써도 됩니다. 오히려 테스트 목적이 명확해져요.
assertEquals(기대값, 실제값)— 두 값이 같은지 비교합니다.
주요 Assertion 메서드
// 값 비교
assertEquals(5, result); // 같은지
assertNotEquals(0, result); // 다른지
// 참/거짓
assertTrue(list.isEmpty()); // 참인지
assertFalse(user.isBlocked()); // 거짓인지
// null 검사
assertNull(deletedUser); // null인지
assertNotNull(savedUser); // null이 아닌지
// 예외 검증
assertThrows(IllegalArgumentException.class, () -> {
calc.divide(10, 0); // 0으로 나누면 예외 발생
});
// 예외 메시지까지 검증
Exception ex = assertThrows(IllegalArgumentException.class, () -> {
calc.divide(10, 0);
});
assertEquals("0으로 나눌 수 없습니다", ex.getMessage());
// 여러 검증을 한 번에 — 하나가 실패해도 나머지를 계속 실행
assertAll(
() -> assertEquals("홍길동", user.getName()),
() -> assertEquals(25, user.getAge()),
() -> assertNotNull(user.getEmail())
);
assertAll은 일반적인 assertion과 동작이 다릅니다. 첫 번째 assertion이 실패해도 나머지를 계속 실행하므로, 모든 검증 결과를 한 번에 볼 수 있어요. 여러 필드를 동시에 검증할 때 유용합니다.
3. 생명주기 — @BeforeEach, @AfterEach, @BeforeAll, @AfterAll
테스트 메서드마다 반복되는 준비/정리 작업이 있으면 생명주기 어노테이션을 사용합니다.
class UserServiceTest {
private UserService service;
private UserRepository repo;
@BeforeAll
static void 전체_초기화() {
// 모든 테스트 전에 한 번만 실행
// static 메서드여야 한다
System.out.println("테스트 시작");
}
@BeforeEach
void 각_테스트_전() {
// 각 테스트 메서드 실행 전마다 호출
repo = new FakeUserRepository();
service = new UserService(repo);
}
@AfterEach
void 각_테스트_후() {
// 각 테스트 메서드 실행 후마다 호출
// 리소스 정리 등
}
@AfterAll
static void 전체_정리() {
// 모든 테스트 후에 한 번만 실행
System.out.println("테스트 종료");
}
@Test
void 사용자_생성() {
// service와 repo는 @BeforeEach에서 이미 준비됨
User user = service.create("홍길동");
assertNotNull(user.getId());
}
}
실행 순서
@BeforeAll (한 번)
├─ @BeforeEach → @Test 메서드 1 → @AfterEach
├─ @BeforeEach → @Test 메서드 2 → @AfterEach
└─ @BeforeEach → @Test 메서드 3 → @AfterEach
@AfterAll (한 번)
핵심 포인트:
- JUnit 5는 각 테스트 메서드마다 새 인스턴스를 생성 합니다. 그래서
@BeforeAll/@AfterAll은 기본적으로static이어야 해요. @BeforeEach에서 매번 새 객체를 만들면 테스트 간 상태가 격리 됩니다. 이게 정말 중요해요.
4. @DisplayName과 @Nested — 테스트 가독성 높이기
테스트가 많아지면 결과를 읽기가 힘들어집니다. JUnit 5는 이를 위한 도구를 제공해요.
@DisplayName
@DisplayName("Calculator 테스트")
class CalculatorTest {
@Test
@DisplayName("양수 두 개를 더하면 합을 반환한다")
void addPositiveNumbers() {
assertEquals(5, new Calculator().add(2, 3));
}
@Test
@DisplayName("0으로 나누면 IllegalArgumentException이 발생한다")
void divideByZero() {
assertThrows(IllegalArgumentException.class,
() -> new Calculator().divide(10, 0));
}
}
테스트 결과에 addPositiveNumbers 대신 "양수 두 개를 더하면 합을 반환한다" 가 표시됩니다. IDE에서 한눈에 파악할 수 있어요.
@Nested — 테스트를 계층 구조로
@DisplayName("OrderService")
class OrderServiceTest {
@Nested
@DisplayName("주문 생성 시")
class CreateOrder {
@Test
@DisplayName("재고가 충분하면 주문이 성공한다")
void 재고_충분() {
// ...
}
@Test
@DisplayName("재고가 부족하면 예외가 발생한다")
void 재고_부족() {
// ...
}
}
@Nested
@DisplayName("주문 취소 시")
class CancelOrder {
@Test
@DisplayName("배송 전이면 취소가 가능하다")
void 배송_전_취소() {
// ...
}
@Test
@DisplayName("배송 후에는 취소가 불가능하다")
void 배송_후_취소() {
// ...
}
}
}
실행 결과가 트리 구조로 보인다:
OrderService
├── 주문 생성 시
│ ├── ✅ 재고가 충분하면 주문이 성공한다
│ └── ✅ 재고가 부족하면 예외가 발생한다
└── 주문 취소 시
├── ✅ 배송 전이면 취소가 가능하다
└── ✅ 배송 후에는 취소가 불가능하다
@Nested로 시나리오별 그룹핑을 하면 테스트가 많아져도 구조가 명확하게 유지됩니다.
5. @ParameterizedTest — 여러 입력값으로 반복 테스트
같은 로직을 다른 입력값으로 여러 번 테스트하고 싶을 때 사용합니다. @Test 대신 @ParameterizedTest를 쓰면 돼요.
@ValueSource — 단일 값 목록
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void 양수_판별(int number) {
assertTrue(number > 0);
}
@CsvSource — 입력과 기대값 쌍
@ParameterizedTest
@CsvSource({
"1, 2, 3", // 1 + 2 = 3
"10, 20, 30", // 10 + 20 = 30
"-1, 1, 0" // -1 + 1 = 0
})
void 덧셈_테스트(int a, int b, int expected) {
assertEquals(expected, new Calculator().add(a, b));
}
@MethodSource — 복잡한 데이터는 메서드로
@ParameterizedTest
@MethodSource("userProvider")
void 사용자_이름_검증(String name, boolean expected) {
assertEquals(expected, UserValidator.isValidName(name));
}
// 테스트 데이터를 제공하는 정적 메서드
static Stream<Arguments> userProvider() {
return Stream.of(
Arguments.of("홍길동", true), // 정상 이름
Arguments.of("", false), // 빈 문자열
Arguments.of(null, false), // null
Arguments.of("a".repeat(51), false) // 50자 초과
);
}
@ParameterizedTest를 쓰면 테스트 메서드 하나로 수십 가지 케이스를 커버할 수 있습니다. 경계값 테스트에 특히 유용해요. 참고로 @NullAndEmptySource를 함께 쓰면 null과 빈 문자열도 자동으로 테스트할 수 있습니다.
6. Mockito 기본 — @Mock, @InjectMocks, when/thenReturn, verify
실제 서비스를 테스트할 때 문제가 하나 있습니다. UserService를 테스트하고 싶은데, 내부에서 UserRepository(DB 접근)를 쓰거든요. 테스트할 때마다 DB를 띄울 수는 없잖아요.
이럴 때 Mockito 로 가짜 객체(Mock)를 만듭니다.
의존성 추가
dependencies {
testImplementation 'org.mockito:mockito-core:5.11.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0'
}
기본 사용법
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class) // Mockito 활성화
class UserServiceTest {
@Mock
UserRepository userRepository; // 가짜 객체 생성
@InjectMocks
UserService userService; // Mock을 주입한 실제 객체
@Test
void 사용자_조회_성공() {
// given - Mock 행동 정의 (스텁)
User fakeUser = new User(1L, "홍길동");
when(userRepository.findById(1L)).thenReturn(Optional.of(fakeUser));
// when - 테스트 대상 실행
User result = userService.findById(1L);
// then - 검증
assertEquals("홍길동", result.getName());
verify(userRepository).findById(1L); // findById가 1번 호출되었는지 검증
}
@Test
void 존재하지_않는_사용자_조회() {
// given
when(userRepository.findById(999L)).thenReturn(Optional.empty());
// when & then
assertThrows(UserNotFoundException.class,
() -> userService.findById(999L));
}
}
핵심 개념 정리
| Mockito 기능 | 설명 |
|---|---|
@Mock | 가짜 객체 생성. 모든 메서드가 기본값(null, 0, false) 반환 |
@InjectMocks | @Mock으로 만든 객체를 자동으로 주입 |
when().thenReturn() | "이 메서드가 호출되면 이 값을 반환해라" (스텁 설정) |
when().thenThrow() | "이 메서드가 호출되면 이 예외를 던져라" |
verify() | 특정 메서드가 호출되었는지 검증 |
verify(mock, times(2)) | 정확히 2번 호출되었는지 검증 |
verify(mock, never()) | 한 번도 호출되지 않았는지 검증 |
verify는 "이 동작이 정말 일어났는가?"를 확인할 때 씁니다. when/thenReturn이 입력 → 출력 을 제어하는 거라면, verify는 부수 효과(side effect) 를 검증하는 역할이에요. 예를 들어 회원가입 시 이메일 발송이 호출되었는지 verify(emailService).sendWelcomeEmail(anyString())처럼 확인할 수 있습니다.
7. 테스트 더블 — Stub, Mock, Spy의 차이
"가짜 객체"를 통칭해서 테스트 더블(Test Double) 이라고 부릅니다. 영화에서 배우 대신 위험한 장면을 찍는 스턴트 더블에서 따온 용어예요. 종류가 여러 가지 있는데, 자주 물어보는 세 가지만 짚어볼게요.
Stub (스텁)
- 미리 준비된 답을 반환 하는 객체
- "이걸 물어보면 이걸 답해" 수준
- Mockito의
when().thenReturn()이 스텁 설정
// "findById(1L)이 호출되면 fakeUser를 반환해라" → 이게 스텁
when(userRepository.findById(1L)).thenReturn(Optional.of(fakeUser));
Mock (모의 객체)
- 스텁 기능 + 호출 여부를 검증 할 수 있는 객체
- "이 메서드가 호출되었는지"까지 확인 가능
- Mockito의
verify()를 쓰면 Mock으로 쓰는 것
// 스텁 설정 (Stub 역할)
when(userRepository.save(any())).thenReturn(savedUser);
// 호출 검증 (Mock 역할)
verify(userRepository).save(any());
Spy (스파이)
- 실제 객체를 감싸서 일부 동작만 가로채는 객체
- 기본적으로 실제 메서드가 호출되고, 원하는 메서드만 오버라이드
@Spy
List<String> spyList = new ArrayList<>();
@Test
void spy_테스트() {
spyList.add("one"); // 실제 add() 호출
spyList.add("two"); // 실제 add() 호출
assertEquals(2, spyList.size()); // 실제 크기: 2
// 특정 메서드만 스텁
doReturn(100).when(spyList).size();
assertEquals(100, spyList.size()); // 스텁된 값: 100
}
한눈에 비교
| 종류 | 실제 동작 | 반환값 제어 | 호출 검증 |
|---|---|---|---|
| Stub | X | O | X |
| Mock | X | O | O |
| Spy | O (기본) | O (부분 오버라이드) | O |
핵심 구분: Mock은 ** 행위 검증 **(behavior verification)에, Stub은 ** 상태 검증 **(state verification)에 초점을 맞춥니다. Martin Fowler의 Mocks Aren't Stubs 글에서 정리된 개념이에요.
8. given-when-then 패턴 — BDD 스타일 테스트 구조화
테스트 코드가 길어지면 "이 줄이 뭘 하는 거지?" 하고 헤매기 쉽습니다. given-when-then 패턴으로 구조를 잡으면 가독성이 확 올라가요.
@Test
@DisplayName("잔액이 충분하면 출금에 성공한다")
void 출금_성공() {
// given — 사전 조건, 테스트에 필요한 데이터 준비
Account account = new Account(10000);
// when — 테스트 대상 동작 실행
account.withdraw(3000);
// then — 결과 검증
assertEquals(7000, account.getBalance());
}
세 단계의 의미
| 단계 | 의미 | 예시 |
|---|---|---|
| given | 어떤 상황이 주어졌을 때 | 잔액 10,000원 계좌 |
| when | 어떤 동작을 하면 | 3,000원 출금 |
| then | 어떤 결과가 나와야 한다 | 잔액이 7,000원 |
Mockito BDD 스타일
Mockito는 BDD 스타일을 위한 별도 API도 제공합니다:
import static org.mockito.BDDMockito.*;
@Test
@DisplayName("주문 생성 시 재고가 차감된다")
void 주문_재고_차감() {
// given
Product product = new Product(1L, "노트북", 10);
given(productRepository.findById(1L)) // when 대신 given 사용
.willReturn(Optional.of(product)); // thenReturn 대신 willReturn
// when
orderService.createOrder(1L, 2);
// then
then(productRepository).should().save(any(Product.class)); // verify 대신 then/should
assertEquals(8, product.getStock());
}
when/thenReturn → given/willReturn, verify → then/should. 의미적으로 given-when-then 흐름에 더 자연스럽게 녹아듭니다. 하지만 기존 스타일과 혼용하는 팀도 많으니, 팀 컨벤션을 따르면 돼요.
9. 테스트 커버리지 — JaCoCo와 100%의 함정
JaCoCo란?
JaCoCo(Java Code Coverage) 는 테스트가 소스 코드의 얼마나 많은 부분을 실행했는지 측정하는 도구입니다.
// build.gradle
plugins {
id 'jacoco'
}
jacoco {
toolVersion = "0.8.12"
}
jacocoTestReport {
reports {
html.required = true // HTML 리포트 생성
}
}
// 최소 커버리지 설정 (선택)
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.80 // 80% 이상 커버리지 필수
}
}
}
}
./gradlew test jacocoTestReport를 실행하면 build/reports/jacoco/ 폴더에 HTML 리포트가 생깁니다.
커버리지의 종류
| 종류 | 측정 대상 | 설명 |
|---|---|---|
| 라인 커버리지 | 코드 줄 | 각 줄이 한 번 이상 실행되었는지 |
| ** 브랜치 커버리지** | if/else 분기 | 모든 분기가 실행되었는지 |
| ** 메서드 커버리지** | 메서드 | 각 메서드가 호출되었는지 |
100% 커버리지의 함정
커버리지 100%를 달성해도 버그 없음을 보장하지 않습니다. 이유는 간단해요:
// 이 테스트는 커버리지를 올리지만, 의미 있는 검증을 하지 않는다
@Test
void 의미없는_테스트() {
UserService service = new UserService(mockRepo);
service.findById(1L); // 호출만 하고 결과는 안 본다
}
모든 줄을 "실행"만 해도 커버리지는 올라갑니다. 하지만 ** 결과를 검증하지 않으면** 의미가 없어요.
현실적인 가이드라인:
- 70~80% 정도를 팀 목표로 잡는 경우가 많습니다
- 비즈니스 핵심 로직은 90% 이상 을 노리되, getter/setter 같은 단순 코드는 무리하게 테스트하지 않아요
- 커버리지보다 중요한 건 의미 있는 테스트를 작성하는 것 입니다
- 경계값(0, null, 빈 문자열, 최대값)을 빠뜨리지 않았는지가 더 중요해요
10. TDD 간단 소개 — Red-Green-Refactor
TDD(Test-Driven Development)는 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 나중에 구현 하는 방법론입니다.
Red-Green-Refactor 사이클
┌─────────────────────┐
│ │
▼ │
🔴 Red │
실패하는 테스트 작성 │
│ │
▼ │
🟢 Green │
테스트를 통과하는 │
최소한의 코드 작성 │
│ │
▼ │
🔵 Refactor │
코드를 깔끔하게 정리 │
│ │
└─────────────────────┘
예시: 문자열 뒤집기
Step 1 — Red: 실패하는 테스트 먼저 작성
@Test
void 문자열_뒤집기() {
StringUtils utils = new StringUtils();
assertEquals("cba", utils.reverse("abc"));
// StringUtils 클래스도 없고, reverse 메서드도 없다 → 컴파일 에러 (Red)
}
Step 2 — Green: 테스트를 통과하는 최소 코드 작성
class StringUtils {
String reverse(String input) {
return new StringBuilder(input).reverse().toString();
}
}
// 테스트 통과! (Green)
Step 3 — Refactor: null 처리, 빈 문자열 처리 등 개선. 그에 맞는 테스트(null_입력시_null_반환, 빈_문자열_입력시_빈_문자열_반환)도 추가합니다.
class StringUtils {
String reverse(String input) {
if (input == null || input.isEmpty()) {
return input; // null이나 빈 문자열은 그대로 반환
}
return new StringBuilder(input).reverse().toString();
}
}
TDD의 장단점
| 장점 | 단점 |
|---|---|
| 설계를 먼저 생각하게 된다 | 초기 개발 속도가 느려질 수 있다 |
| 자연스럽게 테스트 가능한 코드가 나온다 | 익숙해지기까지 학습 비용이 있다 |
| 리팩토링이 안전하다 | 요구사항이 자주 바뀌면 테스트도 같이 바꿔야 한다 |
| 과도한 구현(over-engineering) 방지 | 팀 전체가 동의해야 효과적이다 |
솔직히 말하면, 실무에서 100% TDD를 하는 팀은 많지 않습니다. 하지만 TDD의 사이클을 이해하고 있으면 "테스트를 어떻게 짜야 할지 모르겠다"는 상황이 줄어들어요. ** 복잡한 비즈니스 로직이나 버그 수정 시 부분적으로 TDD를 적용 **하는 것이 현실적인 접근입니다.
11. 정리 테이블
JUnit 5 핵심 어노테이션
| 어노테이션 | 용도 |
|---|---|
@Test | 테스트 메서드 지정 |
@DisplayName | 테스트 이름을 읽기 좋게 표시 |
@Nested | 테스트 클래스를 계층 구조로 |
@BeforeEach / @AfterEach | 각 테스트 전/후 실행 |
@BeforeAll / @AfterAll | 모든 테스트 전/후 한 번 실행 (static) |
@ParameterizedTest | 여러 입력값으로 반복 테스트 |
@ValueSource | 단일 값 목록 제공 |
@CsvSource | CSV 형식으로 입력-기대값 쌍 제공 |
@MethodSource | 메서드로 복잡한 테스트 데이터 제공 |
@Disabled | 테스트 비활성화 (건너뛰기) |
Mockito 핵심 정리
| 기능 | 코드 | 설명 |
|---|---|---|
| Mock 생성 | @Mock | 가짜 객체 생성 |
| Mock 주입 | @InjectMocks | Mock을 자동 주입 |
| 스텁 설정 | when().thenReturn() | 반환값 지정 |
| 예외 스텁 | when().thenThrow() | 예외 발생 지정 |
| 호출 검증 | verify(mock).method() | 호출 여부 확인 |
| 인자 매처 | any(), eq(value) | 유연한 인자 매칭 |
테스트 더블 비교
| 종류 | 핵심 | 언제 쓰나 |
|---|---|---|
| Stub | 정해진 답 반환 | 외부 의존성의 반환값을 고정하고 싶을 때 |
| Mock | 답 반환 + 호출 검증 | 메서드 호출 여부까지 확인하고 싶을 때 |
| Spy | 실제 동작 + 부분 오버라이드 | 실제 객체 대부분의 동작을 유지하되 일부만 바꿀 때 |
주의할 점
Mock 남용은 테스트를 깨지기 쉽게 만듭니다
모든 의존성을 Mock으로 만들면, 테스트가 ** 구현 세부사항에 결합 **됩니다. 메서드 호출 순서나 내부 로직이 바뀌면 기능은 정상인데 테스트가 깨져요. 가능하면 Fake 객체(간단한 인메모리 구현)를 쓰고, Mock은 외부 시스템(DB, HTTP) 접점에만 사용하는 것이 좋습니다.
@BeforeEach에서 매번 새 객체를 만들어야 합니다
JUnit 5는 각 테스트 메서드마다 새 테스트 인스턴스를 생성하지만, @BeforeEach에서 테스트 대상 객체를 새로 만들지 않으면 ** 이전 테스트의 상태가 남아 있을 수 있습니다.** 테스트 간 격리가 안 되면 실행 순서에 따라 결과가 달라지는 문제가 발생해요.
커버리지 100%는 버그 없음을 보장하지 않습니다
모든 코드 줄을 "실행"만 해도 커버리지는 올라갑니다. 하지만 결과를 검증하지 않는 테스트는 의미가 없어요. 경계값(0, null, 빈 문자열, 최대값) 테스트가 빠져 있으면 커버리지 수치와 무관하게 버그가 숨어 있을 수 있습니다.
정리
| 개념 | 핵심 정리 |
|---|---|
| JUnit 5 | @Test, assertEquals, assertAll, 생명주기 어노테이션으로 테스트 구조화 |
| @ParameterizedTest | 같은 로직을 다른 입력으로 반복. @CsvSource, @MethodSource로 데이터 제공 |
| Mockito | @Mock으로 가짜 객체 생성, when/thenReturn으로 스텁, verify로 호출 검증 |
| ** 테스트 더블** | Stub(정해진 답), Mock(답+호출 검증), Spy(실제 동작+부분 오버라이드) |
| given-when-then | 준비-실행-검증 구조. 주석으로라도 구분하면 가독성이 올라간다 |
| JaCoCo | 라인/브랜치/메서드 커버리지 측정. 숫자보다 의미 있는 테스트가 중요하다 |
| TDD | Red-Green-Refactor 사이클. 복잡한 비즈니스 로직에 부분 적용부터 시작 |