18.1 JUnit 5로 시작하는 테스트
JUnit 5는 자바 생태계에서 가장 널리 사용되는 단위 테스트(Unit Test) 프레임워크입니다. "잘 만든 코드"의 증거는 테스트입니다. 테스트 코드를 작성하는 습관은 버그를 줄이고 리팩토링을 안전하게 만들어줍니다.
1. 왜 테스트를 작성하는가?
- 버그 조기 발견: 코드 변경 후 기존 기능이 여전히 잘 동작하는지 확인
- 자신감 있는 리팩토링: 테스트가 통과하면 내부 구현을 마음껏 개선 가능
- 살아있는 문서: 잘 작성된 테스트는 코드의 사용법을 설명
- 설계 개선: 테스트하기 어려운 코드는 대개 설계에 문제가 있음
2. JUnit 5 설정 (build.gradle / pom.xml)
// build.gradle (Gradle)
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
<!-- pom.xml (Maven) -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
3. 첫 번째 테스트 작성
테스트할 클래스:
// src/main/java/Calculator.java
public class Calculator {
public int add(int a, int b) { return a + b; }
public int subtract(int a, int b) { return a - b; }
public int multiply(int a, int b) { return a * b; }
public double divide(int a, int b) {
if (b == 0) throw new IllegalArgumentException("0으로 나눌 수 없습니다.");
return (double) a / b;
}
}
테스트 클래스:
// src/test/java/CalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
Calculator calculator;
@BeforeEach // 각 테스트 메서드 실행 전 호출
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("덧셈: 3 + 5 = 8")
void testAdd() {
int result = calculator.add(3, 5);
assertEquals(8, result, "덧셈 결과가 올바르지 않습니다");
}
@Test
@DisplayName("뺄셈: 10 - 4 = 6")
void testSubtract() {
assertEquals(6, calculator.subtract(10, 4));
}
@Test
@DisplayName("곱셈: 3 * 7 = 21")
void testMultiply() {
assertEquals(21, calculator.multiply(3, 7));
}
@Test
@DisplayName("나눗셈: 10 / 4 = 2.5")
void testDivide() {
assertEquals(2.5, calculator.divide(10, 4), 0.001);
}
@Test
@DisplayName("0으로 나누면 예외 발생")
void testDivideByZero() {
assertThrows(IllegalArgumentException.class, () -> {
calculator.divide(10, 0);
});
}
}
4. 핵심 어노테이션
| 어노테이션 | 설명 |
|---|---|
@Test | 테스트 메서드 표시 |
@DisplayName | 테스트 이름 (한글 가능) |
@BeforeEach | 각 테스트 전 실행 (초기화) |
@AfterEach | 각 테스트 후 실행 (정리) |
@BeforeAll | 모든 테스트 전 딱 한 번 실행 (static 필요) |
@AfterAll | 모든 테스트 후 딱 한 번 실행 (static 필요) |
@Disabled | 테스트 비활성화 |
@Nested | 중첩 테스트 클래스 |
@Tag | 테스트 태그 지정 (그룹화) |
class LifecycleTest {
@BeforeAll
static void beforeAll() {
System.out.println("모든 테스트 시작 전 (DB 연결 등)");
}
@AfterAll
static void afterAll() {
System.out.println("모든 테스트 종료 후 (DB 연결 해제 등)");
}
@BeforeEach
void beforeEach() {
System.out.println("각 테스트 시작 전 (초기화)");
}
@AfterEach
void afterEach() {
System.out.println("각 테스트 종료 후 (정리)");
}
@Test void test1() { System.out.println("테스트 1"); }
@Test
@Disabled("아직 구현 중")
void testSkipped() { System.out.println("이 테스트는 실행되지 않음"); }
}
5. Assertions (검증 메서드)
import static org.junit.jupiter.api.Assertions.*;
@Test
void assertionsDemo() {
// 기본 검증
assertEquals(3, 1 + 2);
assertNotEquals(5, 1 + 2);
assertTrue(5 > 3);
assertFalse(3 > 5);
assertNull(null);
assertNotNull("hello");
// 배열/리스트 비교
assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2, 3});
// 예외 검증
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> { throw new IllegalArgumentException("테스트 에러"); }
);
assertEquals("테스트 에러", ex.getMessage());
// 실행 시간 검증
assertTimeout(java.time.Duration.ofMillis(100), () -> {
Thread.sleep(50); // 100ms 이내에 완료해야 함
});
// 여러 검증을 한 번에 (하나 실패해도 모두 실행)
assertAll("사용자 검증",
() -> assertEquals("홍길동", "홍길동"),
() -> assertTrue(30 >= 18),
() -> assertNotNull("email@example.com")
);
// 실패 메시지 (Supplier로 지연 평가 - 성능 최적화)
assertEquals(10, 5 + 5, () -> "5 + 5는 10이어야 하지만 결과가 다릅니다");
}
6. 파라미터화 테스트 (Parameterized Tests)
같은 테스트를 여러 입력값으로 반복 실행합니다.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class ParameterizedTestDemo {
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5}) // 단순 값 목록
@DisplayName("양수 판별")
void testPositive(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@CsvSource({ // CSV 형태로 입력값, 기대값 쌍
"3, 5, 8",
"10, -3, 7",
"0, 0, 0",
"-1, -1, -2"
})
@DisplayName("덧셈 다중 케이스")
void testAddMultiple(int a, int b, int expected) {
Calculator calc = new Calculator();
assertEquals(expected, calc.add(a, b));
}
@ParameterizedTest
@MethodSource("provideStrings") // 메서드에서 인수 제공
@DisplayName("빈 문자열 검사")
void testIsBlank(String input, boolean expected) {
assertEquals(expected, input.isBlank());
}
static java.util.stream.Stream<Arguments> provideStrings() {
return java.util.stream.Stream.of(
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("hello", false),
Arguments.of(" hello ", false)
);
}
@ParameterizedTest
@EnumSource(value = java.time.DayOfWeek.class,
names = {"SATURDAY", "SUNDAY"}) // enum 값
@DisplayName("주말 확인")
void testWeekend(java.time.DayOfWeek day) {
assertTrue(day == java.time.DayOfWeek.SATURDAY
|| day == java.time.DayOfWeek.SUNDAY);
}
}
7. @Nested - 계층적 테스트 구조
@DisplayName("사용자 서비스 테스트")
class UserServiceTest {
UserService userService;
@BeforeEach
void setUp() { userService = new UserService(); }
@Nested
@DisplayName("회원가입")
class SignUp {
@Test @DisplayName("유효한 정보로 가입 성공")
void successWithValidData() { /* ... */ }
@Test @DisplayName("이메일 중복 시 실패")
void failWithDuplicateEmail() { /* ... */ }
@Test @DisplayName("비밀번호 너무 짧으면 실패")
void failWithShortPassword() { /* ... */ }
}
@Nested
@DisplayName("로그인")
class Login {
@Test @DisplayName("올바른 자격증명으로 로그인 성공")
void successWithValidCredentials() { /* ... */ }
@Test @DisplayName("잘못된 비밀번호로 로그인 실패")
void failWithWrongPassword() { /* ... */ }
}
}
고수 팁
좋은 테스트의 원칙 (FIRST):
- Fast: 테스트는 빠르게 실행되어야 합니다 (DB, 네트워크 최소화)
- Independent: 각 테스트는 다른 테스트에 의존하지 않아야 합니다
- Repeatable: 어디서든 같은 결과가 나와야 합니다
- Self-Validating: 성공/실패를 자동으로 판단해야 합니다 (수동 확인 금지)
- Timely: 코드 작성 직후 혹은 전(TDD)에 작성해야 합니다
테스트 메서드 네이밍 컨벤션:
// 방법 1: given_when_then 스타일
@Test void givenNegativeNumber_whenDivide_thenThrowException() {}
// 방법 2: 한글로 명확하게 (@DisplayName 활용)
@Test @DisplayName("음수를 0으로 나누면 IllegalArgumentException 발생") void test() {}
테스트 커버리지 목표: 100%가 목표가 아닙니다. 핵심 비즈니스 로직과 경계 케이스(edge case) 에 집중하세요. 생성자나 단순 getter 테스트는 가치가 낮습니다.