Java 26일 코스 - Day 23: JUnit 5 테스트

Day 23: JUnit 5 테스트

JUnit 5는 Java의 표준 테스트 프레임워크입니다. 테스트 코드는 프로그램이 올바르게 동작하는지 자동으로 확인해주는 검사관 역할을 합니다. 매번 수동으로 확인하는 대신 ./gradlew test 한 줄로 모든 기능을 검증할 수 있습니다.

JUnit 5 기본 테스트

테스트 클래스와 메서드의 기본 구조입니다.

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

// 테스트 대상 클래스
class Calculator {
    int add(int a, int b) { return a + b; }
    int subtract(int a, int b) { return a - b; }
    int multiply(int a, int b) { return a * b; }
    int divide(int a, int b) {
        if (b == 0) throw new ArithmeticException("0으로 나눌 수 없습니다");
        return a / b;
    }
}

// 테스트 클래스
@DisplayName("계산기 테스트")
class CalculatorTest {
    private Calculator calc;

    @BeforeEach // 각 테스트 메서드 전에 실행
    void setUp() {
        calc = new Calculator();
    }

    @Test
    @DisplayName("두 수의 덧셈이 올바르게 동작한다")
    void testAdd() {
        assertEquals(5, calc.add(2, 3));
        assertEquals(0, calc.add(-1, 1));
        assertEquals(-5, calc.add(-2, -3));
    }

    @Test
    @DisplayName("두 수의 뺄셈이 올바르게 동작한다")
    void testSubtract() {
        assertEquals(1, calc.subtract(3, 2));
        assertEquals(-2, calc.subtract(-1, 1));
    }

    @Test
    @DisplayName("0으로 나누면 ArithmeticException이 발생한다")
    void testDivideByZero() {
        ArithmeticException exception = assertThrows(
            ArithmeticException.class,
            () -> calc.divide(10, 0)
        );
        assertEquals("0으로 나눌 수 없습니다", exception.getMessage());
    }

    @Test
    @Disabled("아직 구현되지 않은 기능")
    void testSquareRoot() {
        // TODO: 제곱근 기능 추가 후 테스트 작성
    }

    @AfterEach // 각 테스트 메서드 후에 실행
    void tearDown() {
        calc = null;
    }
}

다양한 Assertion 메서드

테스트 검증에 사용하는 핵심 메서드들입니다.

import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class AssertionExamples {

    @Test
    void basicAssertions() {
        // 동등성 비교
        assertEquals(4, 2 + 2, "2+2는 4여야 합니다");
        assertNotEquals(5, 2 + 2);

        // boolean 검증
        assertTrue(10 > 5, "10은 5보다 커야 합니다");
        assertFalse("".length() > 0);

        // null 검증
        String name = "Java";
        String nullStr = null;
        assertNotNull(name);
        assertNull(nullStr);

        // 같은 객체인지 (참조 비교)
        String a = "hello";
        String b = a;
        assertSame(a, b);
    }

    @Test
    void collectionAssertions() {
        List<String> fruits = List.of("사과", "바나나", "포도");

        // 크기 확인
        assertEquals(3, fruits.size());

        // 포함 여부
        assertTrue(fruits.contains("사과"));

        // 반복 가능 요소 확인 (순서 무관)
        assertIterableEquals(
            List.of("사과", "바나나", "포도"),
            fruits
        );
    }

    @Test
    void exceptionAssertions() {
        // 예외 발생 확인
        assertThrows(NumberFormatException.class, () -> {
            Integer.parseInt("abc");
        });

        // 예외가 발생하지 않는지 확인
        assertDoesNotThrow(() -> {
            Integer.parseInt("123");
        });
    }

    @Test
    void groupedAssertions() {
        String name = "홍길동";
        int age = 25;

        // assertAll: 모든 검증을 한 번에 실행 (하나 실패해도 나머지 계속)
        assertAll("사용자 정보 검증",
            () -> assertNotNull(name, "이름은 null이 아니어야 합니다"),
            () -> assertTrue(name.length() > 0, "이름은 비어있지 않아야 합니다"),
            () -> assertTrue(age > 0, "나이는 양수여야 합니다"),
            () -> assertTrue(age < 150, "나이는 150 미만이어야 합니다")
        );
    }

    @Test
    void timeoutAssertions() {
        // 시간 제한 내에 실행 확인
        assertTimeout(Duration.ofSeconds(2), () -> {
            Thread.sleep(500); // 0.5초 -> 2초 이내이므로 통과
        });
    }
}

파라미터화 테스트

같은 테스트를 다양한 입력값으로 반복 실행합니다.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;

import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.*;

class StringValidator {
    boolean isValidEmail(String email) {
        return email != null && email.matches("[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
    }

    boolean isStrongPassword(String password) {
        if (password == null || password.length() < 8) return false;
        boolean hasUpper = password.chars().anyMatch(Character::isUpperCase);
        boolean hasLower = password.chars().anyMatch(Character::isLowerCase);
        boolean hasDigit = password.chars().anyMatch(Character::isDigit);
        return hasUpper && hasLower && hasDigit;
    }
}

class StringValidatorTest {
    private final StringValidator validator = new StringValidator();

    // @ValueSource: 단일 값 배열
    @ParameterizedTest(name = "유효한 이메일: {0}")
    @ValueSource(strings = {
        "user@example.com",
        "test.name@company.co.kr",
        "admin+tag@gmail.com"
    })
    void validEmails(String email) {
        assertTrue(validator.isValidEmail(email));
    }

    @ParameterizedTest(name = "유효하지 않은 이메일: {0}")
    @ValueSource(strings = {"", "invalid", "@no-user.com", "no-domain@"})
    @NullSource
    void invalidEmails(String email) {
        assertFalse(validator.isValidEmail(email));
    }

    // @CsvSource: 여러 인자 조합
    @ParameterizedTest(name = "{0} + {1} = {2}")
    @CsvSource({
        "1, 2, 3",
        "0, 0, 0",
        "-1, 1, 0",
        "100, 200, 300"
    })
    void testAddition(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }

    // @MethodSource: 메서드에서 인자 제공
    @ParameterizedTest(name = "강한 비밀번호: {0} -> {1}")
    @MethodSource("passwordProvider")
    void testStrongPassword(String password, boolean expected) {
        assertEquals(expected, validator.isStrongPassword(password));
    }

    static Stream<Arguments> passwordProvider() {
        return Stream.of(
            Arguments.of("Abcdef1!", true),
            Arguments.of("StrongP4ss", true),
            Arguments.of("weak", false),
            Arguments.of("nouppercase1", false),
            Arguments.of("NOLOWERCASE1", false),
            Arguments.of("NoDigitsHere", false),
            Arguments.of(null, false)
        );
    }

    // @EnumSource: enum 값 테스트
    @ParameterizedTest
    @EnumSource(java.time.Month.class)
    void allMonthsAreValid(java.time.Month month) {
        assertTrue(month.getValue() >= 1 && month.getValue() <= 12);
    }
}

테스트 구조화 (Nested, Lifecycle)

테스트를 논리적으로 그룹화하고 생명주기를 관리합니다.

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

import java.util.ArrayList;
import java.util.List;

@DisplayName("쇼핑 카트 테스트")
class ShoppingCartTest {
    private List<String> cart;

    @BeforeAll  // 전체 테스트 전 한 번 실행
    static void initAll() {
        System.out.println("테스트 시작");
    }

    @BeforeEach
    void setUp() {
        cart = new ArrayList<>();
    }

    @Nested
    @DisplayName("비어 있는 카트에서")
    class EmptyCart {
        @Test
        @DisplayName("항목 수는 0이다")
        void isEmpty() {
            assertTrue(cart.isEmpty());
            assertEquals(0, cart.size());
        }

        @Test
        @DisplayName("항목 추가 후 크기가 1이 된다")
        void addItem() {
            cart.add("노트북");
            assertEquals(1, cart.size());
            assertTrue(cart.contains("노트북"));
        }
    }

    @Nested
    @DisplayName("항목이 있는 카트에서")
    class NonEmptyCart {
        @BeforeEach
        void addItems() {
            cart.add("노트북");
            cart.add("마우스");
            cart.add("키보드");
        }

        @Test
        @DisplayName("항목 수는 3이다")
        void hasThreeItems() {
            assertEquals(3, cart.size());
        }

        @Test
        @DisplayName("특정 항목을 삭제할 수 있다")
        void removeItem() {
            cart.remove("마우스");
            assertEquals(2, cart.size());
            assertFalse(cart.contains("마우스"));
        }

        @Test
        @DisplayName("전체 비우기가 동작한다")
        void clearCart() {
            cart.clear();
            assertTrue(cart.isEmpty());
        }

        @Test
        @DisplayName("중복 항목 추가가 가능하다")
        void duplicateItem() {
            cart.add("노트북");
            assertEquals(4, cart.size());
            assertEquals(2, cart.stream().filter("노트북"::equals).count());
        }
    }

    @AfterAll
    static void tearDownAll() {
        System.out.println("테스트 완료");
    }
}

오늘의 연습문제

  1. 문자열 유틸 테스트: StringUtils 클래스에 reverse(), isPalindrome(), countVowels() 메서드를 만들고, 각각에 대한 JUnit 5 테스트를 작성하세요. 정상 케이스, 경계 케이스, null/빈 문자열 케이스를 포함하세요.

  2. 파라미터화 테스트: 주민등록번호 검증 메서드를 만들고, @CsvSource@MethodSource를 활용하여 유효/무효 번호 세트로 파라미터화 테스트를 작성하세요.

  3. TDD 실습: RED-GREEN-REFACTOR 사이클로 Stack<T> 클래스를 구현하세요. 먼저 실패하는 테스트를 작성하고, 테스트를 통과하는 최소한의 구현을 하고, 리팩토링하세요. push, pop, peek, isEmpty, size, 언더플로우 예외를 테스트하세요.

이 글이 도움이 되었나요?