Java 17 기준으로 JUnit 5 단위 테스트를 빠르게 시작하고, 읽기 좋은 테스트를 위한 Given-When-Then 패턴과 실무 작성 규칙을 정리합니다.
태그(5개 이내): Java, JUnit5, UnitTest, TDD, Testing
도입 (문제 상황)
기능은 잘 만든 것 같은데, 배포 후에 “이 케이스는 왜 깨졌지?” 같은 이슈가 반복될 때가 있어요. 리팩터링을 하려면 겁부터 나고, 수정 한 줄이 다른 곳을 망가뜨릴까 불안해지기도 합니다. 이럴 때 단위 테스트가 “안전벨트” 역할을 해 줍니다.
핵심 개념 (JUnit 5 기초와 좋은 테스트 작성법)

Java 단위 테스트가 중요한 이유: “변경 비용”을 낮춥니다
단위 테스트의 핵심 가치는 정확도 자체보다도 변경에 대한 자신감입니다. 코드가 커질수록 버그는 “현재 변경”이 아니라 “연쇄 영향”에서 자주 나오는데, 단위 테스트는 그 연쇄를 빠르게 감지해 줍니다.
특히 Java에서는 IDE 리팩터링(메서드 추출, 이름 변경 등)이 강력한 만큼, 테스트가 받쳐주면 리팩터링의 효율이 크게 올라갑니다.
JUnit 5에서 꼭 알아야 할 최소 구성
JUnit 5(= JUnit Jupiter)는 다음 요소만 알아도 시작할 수 있어요.
@Test: 테스트 메서드 표시Assertions: 검증(Assertion) 도구 (assertEquals,assertThrows등)@DisplayName: 실패 로그/리포트에서 읽기 좋은 이름@BeforeEach: 매 테스트 전에 공통 준비 (남용은 주의)
그리고 단위 테스트는 작고 빠르게가 원칙입니다. 외부 시스템(DB, 네트워크, 파일 시스템)에 의존하면 느려지고 불안정해져서, “자주 실행되는 안전벨트”가 되기 어렵습니다.
Given-When-Then 패턴: 테스트를 “문장”처럼 읽히게 만듭니다
좋은 테스트는 성공/실패보다 먼저 의도를 잘 전달해야 합니다. Given-When-Then은 테스트를 다음 흐름으로 고정해 읽기 쉽게 만드는 패턴이에요.
- Given: 입력/상태/준비
- When: 실행(행동)
- Then: 기대 결과 검증
비유하자면, 테스트는 “사건 보고서”에 가깝습니다. “어떤 상황(Given)에서, 무엇을 했더니(When), 결과가 이렇게 나왔다(Then)”가 한눈에 보이면, 실패했을 때 원인 추적도 빨라집니다.
좋은 테스트 vs 나쁜 테스트 선택 가이드
아래 표는 실무에서 자주 갈리는 지점을 정리한 것입니다.
| 항목 | 좋은 테스트(권장) | 나쁜 테스트(지양) |
|---|---|---|
| 범위 | 한 가지 동작/규칙에 집중 | 여러 규칙을 한 테스트에 몰아넣음 |
| 속도/의존성 | 메모리 내에서 빠르게 실행 | DB/네트워크 등 외부 의존 |
| 검증 방식 | 결과(리턴값/상태/예외)를 검증 | 내부 구현(메서드 호출 순서 등)에 과도 의존 |
| 가독성 | Given-When-Then로 의도 명확 | 준비/실행/검증이 뒤섞여 있음 |
| 실패 메시지 | 무엇이 기대와 다른지 명확 | “false였다” 수준으로 단서 부족 |
코드 예제 (복붙해서 바로 실행 가능한 JUnit 5 테스트)
아래 예제는 Java 17 + JUnit 5 기준이며, Money라는 아주 작은 도메인을 두고 “0원 이하는 허용하지 않는다”, “통화가 다르면 더할 수 없다” 같은 규칙을 테스트합니다. Given-When-Then을 주석으로 고정해 두면 팀 내 스타일이 흔들리지 않아요.
실행 방법: Gradle 프로젝트라면
./gradlew test로 바로 실행됩니다.
// 파일 경로 예시
// src/main/java/example/Money.java
package example;
import java.util.Objects;
public final class Money {
private final long amount;
private final String currency;
private Money(long amount, String currency) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
this.amount = amount;
this.currency = Objects.requireNonNull(currency, "currency");
}
public static Money of(long amount, String currency) {
return new Money(amount, currency);
}
public Money plus(Money other) {
Objects.requireNonNull(other, "other");
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(this.amount + other.amount, this.currency);
}
public long amount() {
return amount;
}
public String currency() {
return currency;
}
}
// 파일 경로 예시
// src/test/java/example/MoneyTest.java
package example;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MoneyTest {
@Test
@DisplayName("금액은 0원 이하일 수 없다")
void amountMustBePositive() {
// given
long invalidAmount = 0;
// when & then
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> Money.of(invalidAmount, "KRW"));
assertEquals("amount must be positive", ex.getMessage());
}
@Test
@DisplayName("같은 통화끼리는 더할 수 있다")
void plusWithSameCurrency() {
// given
Money a = Money.of(1_000, "KRW");
Money b = Money.of(500, "KRW");
// when
Money result = a.plus(b);
// then
assertAll(
() -> assertEquals(1_500, result.amount()),
() -> assertEquals("KRW", result.currency())
);
}
@Test
@DisplayName("통화가 다르면 더할 수 없다")
void plusWithDifferentCurrencyThrows() {
// given
Money krw = Money.of(1_000, "KRW");
Money usd = Money.of(1, "USD");
// when & then
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> krw.plus(usd));
assertEquals("currency mismatch", ex.getMessage());
}
}
// (선택) Gradle 설정 예시
// build.gradle
plugins {
id 'java'
}
repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.junit:junit-bom:5.10.2")
testImplementation "org.junit.jupiter:junit-jupiter"
}
test {
useJUnitPlatform()
}
flowchart TB
A["Given: \"상태\"와 \"입력\" 준비"] --> B["When: \"행동\" 실행"]
B --> C["Then: \"결과\" 검증"]
C --> D["테스트 실패 시: \"원인\" 추적이 쉬워짐"]
Given-When-Then 흐름을 고정하면 테스트가 “읽히는 문서”처럼 정리됩니다.
실무 팁
💡 실무에서는
테스트는 “구현”이 아니라 “행동(스펙)”을 검증하게 해 보세요. 예를 들어 plus() 내부에서 어떤 메서드를 호출했는지까지 검증하면, 리팩터링 때 테스트가 함께 깨져서 오히려 발목을 잡습니다. 가능한 한 “입력 → 출력/예외” 중심으로 검증하면 테스트 수명(유지보수성)이 길어집니다.
💡 실무에서는
실패 메시지가 좋은 Assertion을 선택해 보세요. assertTrue(x > 0)는 실패해도 정보가 부족한 경우가 많습니다. assertEquals(expected, actual) 또는 assertThrows처럼 “기대와 실제”가 드러나는 Assertion을 우선하고, 여러 검증이 필요하면 assertAll로 묶어 한 번에 단서를 많이 남기는 편이 디버깅에 유리합니다.
핵심 요약: JUnit 5 단위 테스트는 변경 비용을 낮추는 안전장치입니다.
Given-When-Then으로 테스트 구조를 고정하면 가독성과 유지보수성이 올라갑니다.
외부 의존을 피하고, 행동(결과/예외)을 검증하는 테스트를 우선해 보세요.
다음 글: #35 Java 성능 체크리스트
'JAVA' 카테고리의 다른 글
| Java 의존성 관리 — Maven & Gradle 핵심 (충돌 해결과 멀티 모듈 기초) (1) | 2026.03.02 |
|---|---|
| Java 성능 체크리스트: String 연결부터 메모리 누수 패턴까지 (0) | 2026.03.01 |
| Java 효과적인 로깅 전략: SLF4J + Logback, 로그 레벨 가이드, 안티패턴 정리 (0) | 2026.02.28 |
| Java Virtual Thread — 경량 스레드의 시대 (Project Loom 실무 가이드) (0) | 2026.02.28 |
| Java switch 패턴 매칭 & 향상된 문법 — switch 표현식부터 가드 패턴까지 (0) | 2026.02.27 |