[Java] Junit 5와 테스트 코드

Hood·2025년 10월 24일

Java

목록 보기
5/5
post-thumbnail

들어가기 전

Spring Boot로 API를 개발하면서 테스트 코드의 중요성을 매일 체감하고 있습니다. 견고하고 유지보수하기 좋은 코드를 작성하기 위한 첫걸음은 바로 TDD(Test-Driven Development)이며 Java 진영에서 TDD를 실천하는 가장 기본적인 도구가 바로 JUnit 5입니다.

이번 포스트에서는 JUnit 5를 처음 접하시거나, 개념을 다시 한번 정리하고 싶은 분들을 위해 핵심 어노테이션부터 파라미터화 테스트까지, 실제 도메인 코드를 테스트하는 예제와 함께 정리해 보겠습니다.


1. 테스트 코드를 작성하는 명확한 이유

테스트 코드를 작성하면 개발 과정에서 다음과 같은 실질적인 이점들을 얻을 수 있습니다.

1) 버그의 조기 발견 (비용 절감)

  • 문제: 기능 개발이 완료되고, 모든 코드가 통합된 후(혹은 배포된 후) 버그가 발견되면, 원인을 찾고 수정하는 데 엄청난 시간과 비용이 듭니다.
  • 해결: 테스트 코드는 개발자가 코드를 작성하는 즉시 해당 기능이 의도대로 동작하는지 검증합니다. 기능 단위의 작은 버그를 미리 발견하고 수정할 수 있어, 전체적인 개발 속도와 안정성이 향상됩니다.

2) 안전한 리팩토링 및 기능 추가

  • 문제: 기존 코드를 수정하거나 새로운 기능을 추가할 때, 이 변경이 기존의 잘 동작하던 기능에 악영향을 주지 않을까(사이드 이펙트) 두려움을 느낍니다.
  • 해결: 잘 작성된 테스트 코드는 안전망 역할을 합니다. 코드를 리팩토링한 후 전체 테스트를 실행했을 때 모든 테스트가 통과(Green)한다면, 기능의 동작(행위)은 그대로 유지된다는 것을 보장받을 수 있습니다.

3) 코드의 실행 가능한 문서

  • 문제: 일반적인 문서는 코드가 변경될 때마다 업데이트되지 않아, 결국 실제 코드와 달라지기 쉽습니다.
  • 해결: 테스트 코드는 그 자체로 살아있는 최신 문서입니다.
    • 이름_생성_성공_테스트(): 이 메서드가 성공하는 정상적인 사용법을 보여줍니다.
    • 이름_6자_초과일_때_예외_테스트(): 이 메서드가 실패하는 예외 상황과 비즈니스 정책(6자 초과)을 명확히 알려줍니다.

4) 더 나은 코드 설계

  • 테스트하기 쉬운 코드를 작성하려면, 자연스럽게 각 클래스와 메서드가 하나의 책임(Single Responsibility)을 가지도록 분리하고, 의존성을 낮추는(Loose Coupling) 방향으로 설계하게 됩니다. 이는 결국 유지보수하기 좋은 코드 구조로 이어집니다.

2. JUnit 5의 모듈식 구성

JUnit 5는 이전 버전(JUnit 4)과 달리 하나의 거대한 라이브러리가 아니라
여러 모듈의 조합으로 구성됩니다. 이 구조 덕분에 더 유연하고 확장이 쉬워졌습니다.

  • JUnit Platform (플랫폼)
    • 테스트를 실행하는 런처(Launcher) 역할을 합니다.
    • IDE(IntelliJ, Eclipse)나 빌드 도구(Gradle, Maven)가 테스트를 발견하고 실행할 수 있도록 API를 제공합니다.
    • 다양한 테스트 엔진(Jupiter, Vintage 등)이 이 플랫폼 위에서 동작합니다.
  • JUnit Jupiter (주피터)
    • 우리가 JUnit 5에서 테스트 코드를 작성할 때 사용하는 새로운 프로그래밍 모델입니다.
    • @Test, @ParameterizedTest, @DisplayName 등 이 포스트에서 다룰 대부분의 어노테이션과 API를 제공합니다.
  • JUnit Vintage (빈티지)
    • 하위 호환성을 위한 모듈입니다.
    • 기존에 작성된 JUnit 3 또는 4 버전의 테스트 코드를 JUnit 5 플랫폼에서 그대로 실행할 수 있게 지원합니다.

우리는 주로 JUnit Jupiter의 API를 사용하여 테스트 코드를 작성하고
JUnit Platform이 이 테스트를 실행한다고 쉽게 이해하면 됩니다.


3. JUnit 5 핵심 어노테이션

JUnit 5(Jupiter)를 사용할 때 자주 만나는 핵심 어노테이션들을 공식 문서를 참조하여 정리했습니다.

어노테이션 (Annotation)설명
@Test가장 기본이 되는 어노테이션으로, 해당 메서드가 테스트 메서드임을 선언합니다.
@DisplayName테스트 클래스나 메서드의 이름을 사용자가 읽기 편한 문자열(공백, 이모지 포함)로 지정합니다.
@BeforeEach각각의 @Test 메서드가 실행되기 직전에 매번 실행됩니다. (테스트 셋업용)
@AfterEach각각의 @Test 메서드가 실행된 직후에 매번 실행됩니다. (테스트 자원 해제용)
@BeforeAll해당 클래스의 모든 테스트 메서드 중 최초 한 번만 실행됩니다. (static이어야 함)
@AfterAll해당 클래스의 모든 테스트 메서드가 실행된 후 마지막 한 번만 실행됩니다. (static이어야 함)
@Disabled특정 테스트 클래스나 메서드를 일시적으로 비활성화할 때 사용합니다. (실행되지 않음)
@Nested테스트 클래스 내부에 정적이지 않은 중첩 클래스(Inner Class)를 만들어 테스트를 계층화하고 구조화할 때 사용합니다.
@ParameterizedTest하나의 테스트 메서드에 여러 다른 파라미터 값을 주입하여 반복 테스트할 때 사용합니다.
@ValueSource@ParameterizedTest에 사용할 값의 소스(Source)를 지정합니다. (String, int, long 등 기본 타입 배열 제공)
@CsvSource콤마(CSV)로 구분된 값들을 @ParameterizedTest의 인자로 제공합니다.
@MethodSource특정 메서드의 반환 값(Stream, Collection 등)을 @ParameterizedTest의 인자로 제공합니다.

3-1. Junit 어노테이션 별 간단 예제

@DisplayName

class UserTest {
    @Test
    @DisplayName("사용자는 'dh'라는 유효한 이름으로 생성될 수 있다.")
    void createUser_Success() {
        assertThatCode(() -> new User("dh"))
                .doesNotThrowAnyException();
    }
}

IDE의 테스트 실행 결과창이나 빌드 리포트에 메서드 이름 대신 설정한 문자열이 표시됩니다.
공백, 특수문자 등을 사용하여 테스트의 의도를 훨씬 더 명확하고 가독성 높게 전달할 수 있습니다.

@BeforeEach

class CalculatorTest {
    private Calculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new Calculator(); 
    }

    @Test
    void testAdd() {
        // setUp() 실행 -> testAdd() 실행
        assertEquals(5, calculator.add(2, 3));
    }

    @Test
    void testSubtract() {
        // setUp() 실행 -> testSubtract() 실행
        assertEquals(1, calculator.subtract(3, 2));
    }
}

setUp 메서드에 @BeforeEach가 붙어있으므로 testAdd가 실행되기 전에 한 번
testSubtract가 실행되기 전에 또 한 번 호출됩니다.
각 테스트가 서로에게 영향을 주지 않도록 매번 새로운 객체를 초기화하거나
공통적인 준비 작업을 수행할 때 사용합니다.

@AfterEach

class ResourceTest {
    private SomeResource resource;

    @BeforeEach
    void setUp() {
        resource = new SomeResource(); // 리소스 할당
        resource.open();
    }

    @AfterEach
    void tearDown() {
        // 이 메서드는 @Test가 끝난 후에 매번 호출됩니다.
        resource.close(); // 자원 해제
    }

    @Test
    void testResourceUsage1() {
        // setUp() -> testResourceUsage1() -> tearDown()
        resource.doSomething();
    }

    @Test
    void testResourceUsage2() {
        // setUp() -> testResourceUsage2() -> tearDown()
        resource.doSomethingElse();
    }
}

@AfterEach는 각각의 @Test 메서드 실행이 완료된 직후에 호출됩니다.
tearDown 메서드처럼 테스트 중에 사용했던 파일, 데이터베이스 연결, 네트워크 소켓
등의 외부 자원을 안전하게 해제하는 용도로 주로 사용됩니다.

@BeforeAll

class DatabaseConnectionTest {
    private static DatabaseConnection dbConnection;

    @BeforeAll
    static void connectToDatabase() {
        // 이 메서드는 모든 @Test 실행 전, 클래스에서 딱 한 번만 실행됩니다.
        dbConnection = new DatabaseConnection("jdbc:mysql://localhost:3306/testdb");
        dbConnection.connect();
    }

    @Test
    void testQuery1() {
        // connectToDatabase()가 이미 실행되었음
        // dbConnection 사용
    }

    @Test
    void testQuery2() {
        // dbConnection 사용
    }
}

@BeforeAll은 해당 테스트 클래스의 모든 테스트 메서드가 실행되기 전
최초에 딱 한 번만 실행됩니다. 반드시 static 메서드여야 합니다.
데이터베이스 연결, 무거운 설정 파일 로드 등 모든 테스트가 공통으로 사용하며
한 번만 수행하면 되는 무거운 초기화 작업에 적합합니다.

@AfterAll

class DatabaseConnectionTest {
    private static DatabaseConnection dbConnection;

    @BeforeAll
    static void connectToDatabase() {
        dbConnection = new DatabaseConnection("jdbc:mysql://localhost:3306/testdb");
        dbConnection.connect();
    }

    @AfterAll
    static void disconnectFromDatabase() {
        // 이 메서드는 모든 @Test가 완료된 후, 클래스에서 딱 한 번만 실행됩니다.
        if (dbConnection != null) {
            dbConnection.disconnect();
        }
    }
    
    @Test
    void testQuery1() { /* ... */ }

    @Test
    void testQuery2() { /* ... */ }
}

@AfterAll은 해당 클래스의 모든 테스트 메서드가 실행된 후 마지막에 딱 한 번만 실행됩니다.

@Disabled

class FeatureTest {
    @Test
    void completedFeatureTest() {
        // 이 테스트는 정상 실행됩니다.
    }

    @Disabled("아직 기능이 완성되지 않음")
    @Test
    void incompleteFeatureTest() {
        // 이 테스트는 실행되지 않고 'skip' 처리됩니다.
    }
}

@Disabled 어노테이션이 붙은 테스트 클래스나 메서드는 실행되지 않습니다.
특정 기능을 리팩토링 중이거나 외부 환경 문제로 인해 일시적으로 테스트를 건너뛰고 싶을 때 유용합니다.

@Nested

@DisplayName("User 클래스 테스트")
class UserTest {

    @Test
    void testValidUserCreation() {
        // User 생성 성공 테스트
    }

    @Nested
    @DisplayName("User 이름 유효성 검사")
    class UserNameValidationTest {

        @Test
        @DisplayName("이름이 공백이면 예외가 발생한다")
        void nameIsBlank() {
            // ...
        }

        @Test
        @DisplayName("이름이 6자를 초과하면 예외가 발생한다")
        void nameIsTooLong() {
            // ...
        }
    }
}

@Nested를 사용하면 테스트 클래스 내부에 중첩 클래스(Inner Class)를 선언하여 테스트를 그룹화할 수 있습니다. 위 예시처럼 User라는 큰 맥락 안에서 '이름 유효성 검사'라는 더 작은 관련 테스트들을 UserNameValidationTest라는 중첩 클래스로 묶어, 테스트의 구조와 가독성을 높일 수 있습니다.

@ParameterizedTest

@ParameterizedTest는 단독으로 쓰이지 않고 항상 @ValueSource, @CsvSource, @MethodSource 같은 인자 소스(Argument Source) 어노테이션과 함께 사용됩니다.

@ValueSource

class UserTest {
    @ParameterizedTest
    @ValueSource(strings = {" ", "", "     "})
    void 이름_공백_예외_테스트(String name) {
        assertThatThrownBy(() -> new User(name))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("[ERROR] 이름은 공백일 수 없습니다.");
    }
}

@ParameterizedTest는 이 메서드가 여러 파라미터를 받아 반복 실행됨을 알립니다.
@ValueSource는 가장 간단한 인자 소스로 strings, ints, longs 등 기본 타입이나
문자열 배열을 직접 제공합니다. 위 예제에서는 3개의 문자열(" ", "", " ")이 name 파라미터에
차례대로 주입되어, 이름공백예외_테스트 메서드가 총 3번 실행됩니다.

@CsvSource

class CalculatorTest {
    @ParameterizedTest
    @CsvSource({
        "2, 3, 5",   // a=2, b=3, expected=5
        "10, 5, 15", // a=10, b=5, expected=15
        "0, 0, 0"    // a=0, b=0, expected=0
    })
    
    void testAddition(int a, int b, int expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.add(a, b));
    }
}

@CsvSource는 콤마(comma)로 구분된 값(CSV)을 테스트 메서드의 인자로 제공합니다.
각 문자열("2, 3, 5")이 메서드의 파라미터(a, b, expected)에 순서대로 매핑됩니다.
여러 입력 값과 그에 따른 기대 값을 함께 테스트해야 하는 경우 매우 유용합니다.

@MethodSource

class StringUtilsTest {
    @ParameterizedTest
    @MethodSource("provideStringsForIsBlank")
    void testIsBlank(String input, boolean expected) {
        assertEquals(expected, StringUtils.isBlank(input));
    }

    // @MethodSource가 참조할 static 메서드
    private static Stream<Arguments> provideStringsForIsBlank() {
        return Stream.of(
            Arguments.of(null, true),
            Arguments.of("", true),
            Arguments.of(" ", true),
            Arguments.of("hello", false)
        );
    }
}

@MethodSource는 특정 메서드의 반환 값을 테스트 인자로 사용합니다.
이 메서드(provideStringsForIsBlank)는 static이어야 하며
Stream, Collection, Iterator 또는 Arguments의 배열을 반환해야 합니다.
Arguments.of() 팩토리 메서드를 사용하면 여러 타입의 객체를 인자로 쉽게 전달할 수 있습니다.
복잡한 객체를 생성하거나 동적으로 테스트 케이스를 만들어야 할 때 사용합니다.


4. 실전 예제 분석 (User 도메인)

이제 위에서 배운 어노테이션이 실제 코드에서 어떻게 사용되는지 알아보시죠.

1) 테스트 대상 코드

먼저 우리가 검증해야 할 User 클래스입니다.
생성 시점에 이름(name)에 대한 2가지 유효성 검사 규칙을 가집니다.
1. 이름은 공백이나 null일 수 없다.
2. 이름의 길이는 6자를 초과할 수 없다.

public class User {
    private static final String ERROR_NAME_NULL = "[ERROR] 이름은 공백일 수 없습니다.";
    private static final String ERROR_NAME_OVER_LENGTH = "[ERROR] 이름은 6자를 넘을 수 없습니다.";
    private String name;

    public User(String name) {
        validate(name);
        this.name = name;
    }

    private void validate(String name) {
        validateName(name);
        validateLength(name);
    }

    private void validateName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException(ERROR_NAME_NULL);
        }
    }

    private void validateLength(String name) {
        if (name.length() > 6) {
            throw new IllegalArgumentException(ERROR_NAME_OVER_LENGTH);
        }
    }
}

2) 테스트 코드 및 분석

위 User 클래스의 2가지 규칙(및 성공 케이스)이 잘 동작하는지 검증하는 UserTest입니다.

AssertJ 아래 assertThat...() 코드는 JUnit 5의 기본 검증문이 아닌, AssertJ 라이브러리입니다. AssertJ는 메서드 체이닝을 통해 훨씬 더 읽기 쉽고 풍부한 검증(Assertion)을 지원하여 JUnit과 함께 사실상의 표준처럼 사용됩니다.

public class UserTest {
    @Test
    void 이름_생성_성공_테스트() {
        assertThatCode(() -> new User("dh"))
                .doesNotThrowAnyException();
    }

    @ParameterizedTest
    @ValueSource(strings = {" ", "", "     "})
    void 이름_공백_예외_테스트(String name) {
        assertThatThrownBy(() -> new User(name))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("[ERROR] 이름은 공백일 수 없습니다.");
    }
    
    @Test
    void 이름_6자_초과일_때_예외_테스트() {
        String invalidName = "이름이어떻게6자초과";

        assertThatThrownBy(() -> new User(invalidName))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("[ERROR] 이름은 6자를 넘을 수 없습니다.");
    }
}

5. 결론

이제 테스트 코드는 정말 선택이 아닌 필수가 된 것 같습니다.
오늘 살펴본 JUnit 5의 핵심 기능들로 여러분의 소중한 코드에 안전망을 미리 구축해보는 보는 건 어떨까요?
그렇게 되면 앞으로는 '이거 고쳐도 괜찮을까?' 하는 두려움 없이 미리 리팩토링할 수 있는 거 같습니다.

읽어주셔서 감사합니다!

profile
달을 향해 쏴라, 빗나가도 별이 될 테니 👊

0개의 댓글