본문으로 건너뛰기
Advertisement

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 테스트는 가치가 낮습니다.

Advertisement