콘텐츠 대표 이미지 - 야, 너두 테스트 코드 짤 수 있어! JUnit으로 개발자 레벨업 🚀

야, 너두 테스트 코드 짤 수 있어! JUnit으로 개발자 레벨업 🚀

개발의 퀄리티를 수직 상승시키는 비밀 병기, JUnit.
코딩의 자신감을 풀충전하고 칼퇴를 부르는 단위 테스트의 세계로 초대할게!

프롤로그: "아니, 테스트 코드를 왜 짜요?"

안녕! 코딩의 세계에 발을 들인 우리 동료 개발자 친구들. 👋
매일같이 새로운 기능을 만들고, 버그를 잡고, 리팩토링의 늪에서 허우적대느라 고생이 많아.

그런데 말이야, 혹시 이런 생각해 본 적 없어?
"내가 지금 고친 이 코드 때문에... 다른 멀쩡한 기능이 죽으면 어떡하지?"
"새로운 기능을 추가했는데, 기존 로직이랑 꼬여서 대참사가 나면...?"
"밤새 만든 내 코드가 사실은 거대한 버그 덩어리라면...?"

이런 불안감, 우리 개발자들에겐 거의 뭐 직업병 같은 거잖아. 😅
이 불안감을 해소하고, 내 코드에 대한 확신과 자신감을 갖게 해주는 마법 같은 도구가 바로 **'테스트 코드'**야. 그리고 자바 세상에서 그 마법을 가장 잘 부리는 도구가 바로 **JUnit**이지.

🤔 "테스트 코드? 그거 그냥 기능 잘 돌아가는지 손으로 확인해보면 되는 거 아니에요?"


물론 그것도 테스트지. 하지만 그걸 '수동 테스트'라고 불러. 매번 코드를 수정할 때마다 모든 기능을 일일이 손으로 클릭하고, 값을 입력하고, 결과를 확인하는 과정... 생각만 해도 끔찍하지 않아? 😱

JUnit을 이용한 **'자동화된 단위 테스트'**는 이 모든 과정을 코드로 작성해서, 단 몇 초 만에 수백, 수천 개의 테스트를 실행하고 결과를 알려줘. 이건 마치 내 코드를 24시간 지켜주는 든든한 보디가드를 두는 것과 같아.

이 글에서는 JUnit이 대체 뭐고, 왜 써야 하는지, 그리고 어떻게 써야 내 개발 라이프가 윤택해지는지 A부터 Z까지 친한 친구가 알려주듯 쉽고 재미있게 파헤쳐 볼 거야. 이 글을 다 읽고 나면, 더 이상 테스트 코드 작성을 두려워하지 않고, 오히려 즐기게 될 거라고 확신해. 자, 그럼 시작해볼까?

JUnit 5, 너 정체가 뭐야? (feat. 단위 테스트)

JUnit을 제대로 알려면, 먼저 '단위 테스트(Unit Test)'가 뭔지부터 알아야 해.
어렵게 생각할 거 없어. 말 그대로 **'단위(Unit)'**를 테스트하는 거야.

여기서 '단위'는 뭘까?
보통 프로그래밍에서 의미 있는 가장 작은 단위를 말해. 자바에서는 보통 **메서드(Method)** 하나, 또는 잘 만들어진 **클래스(Class)** 하나가 그 단위가 될 수 있지.

즉, 단위 테스트는 '내가 만든 특정 메서드가 의도한 대로 정확하게 동작하는지'를 검증하는 코드야. 예를 들어, 두 숫자를 더하는 `add(a, b)` 메서드를 만들었다면, `add(2, 3)`을 호출했을 때 정말로 `5`가 반환되는지를 확인하는 코드를 짜는 거지.

이런 단위 테스트를 쉽고 체계적으로 작성할 수 있도록 도와주는 여러 도구와 규칙의 모음을 **'테스트 프레임워크(Test Framework)'**라고 부르고, 자바 진영의 부동의 원탑, 표준이 바로 **JUnit**인 거야.

JUnit의 역사: JUnit 4에서 JUnit 5로의 진화

JUnit도 벌써 버전 5까지 오면서 많은 발전을 거듭했어.
과거의 표준이었던 JUnit 4는 거대한 단일 라이브러리(monolithic) 구조였어. 모든 기능이 `junit.jar` 파일 하나에 몽땅 들어있었지. 쓰기엔 편했지만, 새로운 기능을 추가하거나 자바의 새로운 변화(예: 람다)를 발 빠르게 적용하기엔 좀 무거운 구조였어.

그래서 등장한 게 바로 **JUnit 5**!
JUnit 5는 '모듈화(Modular)'라는 혁신적인 변화를 들고 나왔어. 거대한 통짜 라이브러리가 아니라, 여러 개의 작은 모듈로 분리해서 필요한 것만 쏙쏙 골라 쓸 수 있게 된 거지.

JUnit 5 아키텍처 JUnit Platform JVM에서 테스트 프레임워크를 실행하기 위한 기반 제공 JUnit Jupiter JUnit 5의 새로운 프로그래밍 모델과 확장 모델 API (우리가 주로 사용!) JUnit Vintage JUnit 3, 4로 작성된 기존 테스트를 실행하기 위한 엔진 (하위 호환성) Third-party Engines Spock, Cucumber 등 다른 테스트 프레임워크를 연동하기 위한 엔진

JUnit 5는 크게 3가지 모듈로 구성돼.

  • JUnit Platform: 테스트를 실행시켜주는 런처(Launcher) 역할을 해. IDE(인텔리제이, 이클립스)나 빌드 도구(메이븐, 그래들)가 이 Platform을 통해 테스트를 발견하고 실행하는 거지. 일종의 '무대'라고 생각하면 돼.
  • JUnit Jupiter: 우리가 앞으로 배울 JUnit 5의 새로운 프로그래밍 모델이야. `@Test`, `@DisplayName` 같은 새로운 어노테이션과 강력한 기능들이 모두 여기에 포함돼 있어. '무대 위의 주인공'이지.
  • JUnit Vintage: 과거의 유산, 즉 JUnit 3, 4 버전으로 짜인 테스트 코드를 JUnit 5 환경에서 그대로 실행할 수 있게 해주는 '하위 호환성' 모듈이야. '특별 출연한 원로 배우'랄까?

이런 구조 덕분에 우리는 이제 JUnit Jupiter라는 최신 엔진을 사용하면서도, 필요하다면 과거의 코드나 다른 테스트 프레임워크까지 유연하게 통합할 수 있게 된 거야. 완전 멋지지 않아? 😎

자, 이제 진짜 시작: JUnit 5 첫걸음 떼기

백문이 불여일견! 이론은 이쯤 하고, 직접 코드를 짜면서 감을 익혀보자.
가장 먼저 할 일은 우리 프로젝트에 JUnit 5를 모셔오는 것. 즉, 의존성을 추가하는 일이야.

1. 프로젝트에 JUnit 5 날개 달아주기 (의존성 설정)

요즘은 대부분 메이븐(Maven)이나 그래들(Gradle) 같은 빌드 관리 도구를 사용하니까, 이걸 기준으로 설명할게.

💡 잠깐! Spring Boot를 쓴다면?만약 네가 스프링 부트 프로젝트를 `spring-boot-starter-test` 의존성을 포함해서 생성했다면, 축하해! 넌 아무것도 할 필요가 없어. 스프링 부트가 이미 JUnit 5를 포함한 필수 테스트 라이브러리들을 알아서 다 챙겨주거든. 역시 '근본'은 달라.

Gradle 사용 시 (build.gradle)

가장 많이 쓰는 그래들 설정이야. `build.gradle` 파일의 `dependencies` 블록에 아래 내용을 추가해줘.


dependencies {
    // JUnit 5 (Jupiter) API 및 Engine
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'

    // (선택) 파라미터화된 테스트를 위한 의존성
    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
}

// build.gradle 파일 최상단에 아래 내용 추가
test {
    useJUnitPlatform()
}
        

Maven 사용 시 (pom.xml)

메이븐을 쓴다면 `pom.xml` 파일의 `` 섹션에 아래처럼 추가하면 돼.


<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-params</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>
        

자, 이제 우리 프로젝트는 JUnit 5를 사용할 모든 준비를 마쳤어!

2. 나의 첫 테스트 코드 작성하기

세상에서 제일 간단한 `Calculator` 클래스를 만들고, 이 녀석이 덧셈을 잘하는지 테스트해보자.

Step 1: 테스트할 대상 코드 작성 (Calculator.java)

`src/main/java` 아래에 간단한 계산기 클래스를 하나 만들자.


public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}
        

Step 2: 테스트 코드 작성 (CalculatorTest.java)

이제 이 `Calculator`를 테스트할 코드를 짤 차례야.
테스트 코드는 보통 `src/test/java` 디렉토리에 만들어. 실제 배포될 코드와 분리하기 위해서지.

클래스 이름은 보통 `[테스트할 클래스명]Test` 라고 짓는 게 국룰이야. `Calculator`를 테스트할 거니까 `CalculatorTest`가 되겠지?


import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    @Test
    @DisplayName("두 숫자의 덧셈이 정확한지 테스트한다")
    void add_test() {
        // given (준비)
        Calculator calculator = new Calculator();
        int a = 5;
        int b = 3;

        // when (실행)
        int result = calculator.add(a, b);

        // then (검증)
        assertEquals(8, result, "5 + 3의 결과는 8이어야 합니다.");
    }
}
        

와! 드디어 첫 테스트 코드를 완성했어. 🎉
코드가 좀 낯설지? 하나씩 뜯어보자.

  • @Test: "이 메서드는 테스트 메서드입니다!" 라고 JUnit에게 알려주는 가장 중요한 어노테이션이야. 이게 없으면 그냥 평범한 메서드일 뿐, JUnit이 실행해주지 않아.
  • @DisplayName("..."): 테스트의 목적을 사람이 읽기 좋은 문장으로 설명해주는 어노테이션이야. 이걸 쓰면 나중에 테스트 결과를 볼 때 "add_test" 같은 메서드 이름 대신 "두 숫자의 덧셈이 정확한지 테스트한다" 라고 예쁘게 보여줘. 가독성이 엄청나게 올라가겠지? 강력 추천!
  • assertEquals(expected, actual, message): 이게 바로 **'검증(Assertion)'** 메서드야. "기대하는 값(expected)과 실제 결과값(actual)이 같은지 확인해줘!" 라는 뜻이지. 만약 두 값이 다르면 테스트는 실패하고, 세 번째 인자인 메시지를 보여줘.

3. 테스트의 황금률: Given-When-Then (혹은 Arrange-Act-Assert)

위 예제 코드의 주석을 봤어? `given`, `when`, `then`.
이건 테스트 코드를 작성하는 아주 유명하고 좋은 패턴이야. 이 구조를 따르면 테스트의 의도가 명확해지고, 다른 사람이 읽기도 훨씬 편해져.

  • **Given (준비, Arrange):** 테스트를 실행하기 위해 필요한 모든 상태를 준비하는 단계야. 객체를 생성하고, 필요한 데이터를 세팅하는 등의 작업을 해.
  • **When (실행, Act):** 실제로 테스트하고 싶은 동작, 즉 메서드를 호출하는 단계야.
  • **Then (검증, Assert):** `When` 단계의 결과가 우리가 기대한 대로 나왔는지 검증하는 단계야. `assertEquals` 같은 검증 메서드를 사용해서 확인하지.

이 패턴은 꼭 지켜야 하는 강제 규칙은 아니지만, 좋은 테스트 코드를 만들기 위한 최고의 가이드라인이니까 꼭 기억해두자!

4. JUnit의 핵심, Assertions 파헤치기

`assertEquals` 말고도 JUnit은 다양한 상황에 쓸 수 있는 유용한 검증 메서드들을 잔뜩 제공해. 몇 가지만 더 알아볼까?


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

// ... 테스트 클래스 내부 ...

@Test
@DisplayName("다양한 검증 메서드 사용 예제")
void assertion_examples() {
    String name = "Jaenung";
    String nullName = null;
    boolean isFun = true;

    // assertTrue / assertFalse: 조건이 참/거짓인지 검증
    assertTrue(isFun, "코딩은 재미있어야 합니다!");
    assertFalse(5 < 3, "5는 3보다 작지 않습니다.");

    // assertNull / assertNotNull: 객체가 null인지 아닌지 검증
    assertNull(nullName, "nullName 변수는 null이어야 합니다.");
    assertNotNull(name, "name 변수는 null이 아니어야 합니다.");

    // assertThrows: 특정 예외가 발생하는지 검증
    // 아래 코드는 "10 / 0" 연산을 할 때 ArithmeticException이 발생하는지를 테스트
    assertThrows(ArithmeticException.class, () -> {
        int result = 10 / 0;
    });

    // assertAll: 여러 검증을 한 번에 실행
    // 중간에 하나가 실패해도 멈추지 않고 모두 실행한 뒤 결과를 알려줌
    assertAll("person",
        () -> assertEquals("Kim", "Kim"),
        () -> assertEquals(25, 25)
    );
}
        

특히 `assertThrows`는 예외 상황을 테스트할 때 정말 유용하고, `assertAll`은 여러 속성을 가진 객체를 검증할 때 아주 편리해. 이 정도만 알아도 웬만한 단위 테스트는 문제없이 작성할 수 있을 거야!

JUnit 마스터 클래스: 더 강력한 기능들

기본기는 다졌으니, 이제부터는 JUnit을 한 단계 더 깊게 파고들어 보자.
지금부터 소개할 기능들을 잘 활용하면 테스트 코드의 퀄리티와 효율이 수직 상승할 거야.

1. 테스트 라이프사이클 관리: `@BeforeAll`, `@AfterAll`, `@BeforeEach`, `@AfterEach`

여러 테스트 메서드를 실행하다 보면, 공통적으로 필요한 준비(setup)나 마무리(teardown) 작업이 생기기 마련이야. 예를 들어, 모든 테스트 전에 데이터베이스 연결을 설정하거나, 각 테스트가 끝날 때마다 임시 파일을 삭제하는 것처럼 말이지.

JUnit은 이런 공통 작업을 위한 멋진 어노테이션들을 제공해.


import org.junit.jupiter.api.*;

class LifecycleTest {

    @BeforeAll
    static void beforeAllTests() {
        System.out.println("모든 테스트 시작 전, 딱 한 번만 실행됩니다.");
        // 예: DB 커넥션 풀 초기화, 무거운 설정 파일 로딩 등
    }

    @AfterAll
    static void afterAllTests() {
        System.out.println("모든 테스트가 끝난 후, 딱 한 번만 실행됩니다.");
        // 예: DB 커넥션 풀 자원 해제
    }

    @BeforeEach
    void beforeEachTest() {
        System.out.println("==> 각 테스트 메서드 시작 전에 실행됩니다.");
        // 예: 테스트에 사용할 객체 초기화
    }

    @AfterEach
    void afterEachTest() {
        System.out.println("==> 각 테스트 메서드 끝난 후에 실행됩니다.");
        // 예: 테스트 데이터 정리
    }

    @Test
    void test1() {
        System.out.println("테스트 1 실행");
    }

    @Test
    void test2() {
        System.out.println("테스트 2 실행");
    }
}
        

위 코드를 실행하면 콘솔에 찍히는 순서는 이럴 거야.

  1. 모든 테스트 시작 전, 딱 한 번만 실행됩니다.
  2. ==> 각 테스트 메서드 시작 전에 실행됩니다.
  3. 테스트 1 실행
  4. ==> 각 테스트 메서드 끝난 후에 실행됩니다.
  5. ==> 각 테스트 메서드 시작 전에 실행됩니다.
  6. 테스트 2 실행
  7. ==> 각 테스트 메서드 끝난 후에 실행됩니다.
  8. 모든 테스트가 끝난 후, 딱 한 번만 실행됩니다.

차이가 명확히 보이지?
`@BeforeEach` / `@AfterEach`는 **각각의 `@Test` 메서드마다** 실행돼서, 테스트 간의 독립성을 보장하는 데 아주 중요해. 한 테스트가 다른 테스트에 영향을 주면 안 되니까!
`@BeforeAll` / `@AfterAll`은 **테스트 클래스 전체에서 딱 한 번** 실행돼. 그래서 `static` 메서드여야만 해. 여러 테스트가 공유하는 무거운 자원을 준비하고 해제할 때 유용하지.

2. 테스트 그룹핑과 구조화: `@Nested`와 `@Tag`

테스트 케이스가 많아지면 관리하기가 힘들어져. 이럴 때 테스트를 논리적인 그룹으로 묶어주면 가독성과 구조가 훨씬 좋아져.

`@Nested`로 계층 구조 만들기

`@Nested`는 테스트 클래스 안에 내부 클래스(inner class)를 만들어서, 관련된 테스트들을 하나의 그룹으로 묶어주는 기능이야. 특정 시나리오나 상태에 따른 테스트를 그룹핑할 때 빛을 발하지.


@DisplayName("사용자 관리 기능 테스트")
class UserManagementTest {

    @Nested
    @DisplayName("사용자 생성 시나리오")
    class CreateUserScenario {
        
        @Test
        @DisplayName("필수 정보가 모두 있으면 성공적으로 생성된다")
        void createUser_with_valid_info() { /* ... */ }

        @Test
        @DisplayName("이메일이 중복되면 예외가 발생한다")
        void createUser_with_duplicate_email() { /* ... */ }
    }

    @Nested
    @DisplayName("사용자 조회 시나리오")
    class ReadUserScenario {
        
        @Test
        @DisplayName("존재하는 ID로 조회하면 사용자 정보를 반환한다")
        void findUser_with_existing_id() { /* ... */ }

        @Test
        @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다")
        void findUser_with_non_existing_id() { /* ... */ }
    }
}
        

어때? `@Nested`를 쓰니까 '사용자 생성'과 '사용자 조회'라는 큰 맥락 안에서 세부 테스트들이 착착 정리되는 느낌이지? IDE에서도 계층 구조로 예쁘게 보여줘서 훨씬 보기 좋아.

`@Tag`로 테스트에 꼬리표 달기

`@Tag`는 테스트에 임의의 꼬리표(태그)를 붙이는 기능이야. 이걸 이용하면 특정 태그가 붙은 테스트만 골라서 실행할 수 있어.


class TaggingTest {

    @Test
    @Tag("fast")
    @DisplayName("빠르게 실행되는 단위 테스트")
    void fast_unit_test() { /* ... */ }

    @Test
    @Tag("slow")
    @DisplayName("DB 연동이 필요한 느린 통합 테스트")
    void slow_integration_test() { /* ... */ }
}
        

이렇게 태그를 붙여두면, 빌드 스크립트나 IDE 설정에서 "fast 태그가 붙은 테스트만 실행해줘!" 또는 "slow 태그는 빼고 실행해줘!" 같은 설정이 가능해져.
예를 들어, 개발 중에는 `fast` 테스트만 빠르게 돌려보고, CI/CD 파이프라인에서는 모든 테스트(`fast`, `slow`)를 실행하는 식으로 효율적인 테스트 전략을 짤 수 있지.

3. 테스트 코드의 혁명: `@ParameterizedTest` (파라미터화 테스트)

이건 JUnit 5의 킬러 기능 중 하나야. 정말 정말 중요하고 강력해!
만약 `isPositive(int number)` 라는 메서드를 테스트한다고 생각해봐. 1, 10, 100 등 여러 양수를 넣어서 테스트해봐야겠지? 이걸 `@Test`로 각각 만들면 코드가 중복되고 지저분해져.

이럴 때 `@ParameterizedTest`를 쓰면, 여러 다른 입력값(파라미터)을 가지고 **하나의 테스트 메서드를 여러 번 실행**할 수 있어.

`@ValueSource`: 가장 간단한 파라미터 소스

기본 타입(int, long, double, String 등)의 값들을 직접 제공할 때 사용해.


import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;

class NumberValidatorTest {

    @ParameterizedTest
    @ValueSource(ints = {1, 5, 100, Integer.MAX_VALUE})
    @DisplayName("양수 판별 테스트")
    void isPositive_with_positive_numbers(int number) {
        assertTrue(number > 0);
    }
}
        

이 코드는 `isPositive_with_positive_numbers` 메서드를 총 4번 실행해. `number` 파라미터에 각각 1, 5, 100, `Integer.MAX_VALUE`를 넣어서 말이야. 코드가 엄청나게 깔끔해졌지?

`@CsvSource`: CSV 포맷으로 여러 인자 전달하기

입력값과 기대값을 함께 전달하는 등, 여러 개의 인자가 필요할 때 아주 유용해.


import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CalculatorTest {

    @ParameterizedTest
    @CsvSource({
        "1, 2, 3",
        "5, 5, 10",
        "-1, 1, 0",
        "0, 0, 0"
    })
    @DisplayName("다양한 덧셈 케이스 테스트")
    void parameterized_add_test(int a, int b, int expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.add(a, b));
    }
}
        

CSV(Comma-Separated Values) 문자열 하나가 테스트 케이스 하나가 되는 거야. `"1, 2, 3"`은 `a=1, b=2, expected=3`으로 매핑돼서 테스트를 실행해. 가독성도 좋고, 테스트 케이스를 추가하기도 정말 편해.

💡 더 많은 소스들!JUnit 5는 이 외에도 `@EnumSource`(Enum 상수를 파라미터로), `@CsvFileSource`(CSV 파일을 읽어서 파라미터로), `@MethodSource`(특정 메서드의 반환값을 파라미터로) 등 다양한 소스를 제공해. 상황에 맞게 골라 쓰면 테스트 코드의 재사용성과 효율성을 극대화할 수 있어.

실전! JUnit과 환상의 짝꿍, Mockito

지금까지는 `Calculator`처럼 외부 의존성이 없는 아주 간단한 객체만 테스트했어.
하지만 실제 세상의 코드는 그렇게 단순하지 않지. 대부분의 객체는 다른 객체와 협력해서 동작해. 예를 들어, `UserService`는 `UserRepository`를 이용해서 DB에 접근하고, `OrderService`는 `PaymentGateway`를 통해 결제를 요청하지.

이런 상황에서 `UserService`를 **'단위 테스트'**하고 싶다면 어떻게 해야 할까?
진짜 `UserRepository`를 사용하면 DB까지 연동해야 하잖아. 그럼 이건 더 이상 순수한 단위 테스트가 아니라 DB까지 엮이는 '통합 테스트'가 되어버려. 속도도 느려지고, DB 상태에 따라 테스트 결과가 달라질 수도 있지.

우리가 원하는 건 오직 `UserService`의 로직만을 고립시켜서 테스트하는 거야. 이때 필요한 게 바로 **'테스트 더블(Test Double)'**이고, 그중 가장 유명한 것이 **'목(Mock)'** 객체야.

가짜 객체 만들기: Mockito의 등장

**Mockito**는 자바에서 가장 인기 있는 목(Mock) 프레임워크야.
이 녀석을 사용하면, 실제 객체인 척 연기하는 '가짜 객체'를 아주 쉽게 만들 수 있어.

예를 들어, `UserRepository`의 가짜 객체(Mock)를 만들고, "만약 `findById(1L)` 메서드가 호출되면, 가짜 `User` 객체를 반환해줘!" 라고 미리 행동을 정의(stubbing)해 둘 수 있는 거지.

1. Mockito 의존성 추가

먼저 Mockito를 우리 프로젝트로 데려오자. (스프링 부트 `spring-boot-starter-test`에는 이미 포함되어 있어!)

Gradle (build.gradle)


dependencies {
    // ... 기존 JUnit 의존성 ...
    testImplementation 'org.mockito:mockito-core:4.5.1'
    testImplementation 'org.mockito:mockito-junit-jupiter:4.5.1' // JUnit 5 연동을 위해
}
        

2. Mockito를 이용한 테스트 예제

간단한 `MemberService`와 `MemberRepository`를 예로 들어보자.

테스트 대상 코드


// Member.java
public class Member {
    private Long id;
    private String name;
    // ... 생성자, getter ...
}

// MemberRepository.java (인터페이스)
public interface MemberRepository {
    Optional<Member> findById(Long id);
    void save(Member member);
}

// MemberService.java (우리가 테스트할 클래스)
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public Optional<Member> findMember(Long memberId) {
        return memberRepository.findById(memberId);
    }
}
        

자, 이제 `MemberService`의 `findMember` 메서드를 테스트할 거야. 이때 실제 DB와 연동된 `MemberRepository` 구현체 대신, Mockito로 만든 가짜 `MemberRepository`를 사용할 거야.

Mockito를 사용한 테스트 코드


import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class) // JUnit 5에서 Mockito를 사용하겠다는 선언
class MemberServiceTest {

    @Mock // 가짜(Mock) 객체 생성
    private MemberRepository memberRepository;

    @InjectMocks // @Mock으로 만든 객체를 이 객체에 주입
    private MemberService memberService;

    @Test
    @DisplayName("존재하는 회원 ID로 조회 시 회원 정보를 반환한다")
    void findMember_with_existing_id() {
        // given (준비)
        Long memberId = 1L;
        Member fakeMember = new Member(memberId, "Test User");

        // memberRepository.findById(1L)이 호출되면, 
        // 미리 만들어둔 fakeMember를 담은 Optional을 반환하도록 행동을 정의(stubbing)
        when(memberRepository.findById(memberId)).thenReturn(Optional.of(fakeMember));

        // when (실행)
        Optional<Member> foundMember = memberService.findMember(memberId);

        // then (검증)
        assertTrue(foundMember.isPresent());
        assertEquals("Test User", foundMember.get().getName());
    }
}
        

코드가 조금 복잡해졌지만, 핵심은 이거야.

  • @ExtendWith(MockitoExtension.class): JUnit 5에게 "나 Mockito 기능 쓸 거니까 준비 좀 해줘!" 라고 알려주는 거야.
  • @Mock: `MemberRepository` 타입의 가짜 객체를 만들어줘!
  • @InjectMocks: `MemberService` 객체를 만들 때, 생성자에 `@Mock`으로 만든 `memberRepository` 가짜 객체를 자동으로 주입해줘!
  • when( ... ).thenReturn( ... ): Mockito의 핵심! **"만약 ~가 호출되면, ~를 반환해라"** 라고 가짜 객체의 행동을 정의하는 부분이야. 위 예제에서는 `memberRepository.findById(1L)`이 호출되면 `Optional.of(fakeMember)`를 반환하도록 설정했지.

이렇게 하면, 우리는 DB 연결 없이도 `MemberService`가 `MemberRepository`로부터 받은 데이터를 잘 처리하는지, 그 로직 자체에만 집중해서 테스트할 수 있게 돼. 이게 바로 단위 테스트의 정수라고 할 수 있지.

이처럼 JUnit은 테스트의 구조와 실행을 담당하고, Mockito는 테스트 대상의 의존성을 가짜로 대체하는 역할을 하면서 환상의 시너지를 내. 실무에서는 이 두 가지를 함께 사용하는 게 거의 표준이야.

에필로그: 테스트 코드는 사랑입니다 ❤️

자, 지금까지 JUnit의 세계를 함께 여행해 봤어. 어때? 생각보다 어렵지 않지?
처음에는 테스트 코드를 작성하는 게 귀찮고, 오히려 개발 시간을 뺏는다고 느껴질 수도 있어. 나도 그랬으니까.

하지만 장담할게. 테스트 코드를 작성하는 습관을 들이는 순간, 너의 개발 라이프는 180도 달라질 거야.

  • 더 이상 코드를 수정할 때마다 불안에 떨지 않아도 돼. 잘 짜인 테스트 스위트가 너의 든든한 안전망이 되어줄 테니까.
  • 과감한 리팩토링이 가능해져. 테스트가 통과하는 한, 내부 구조를 어떻게 바꾸든 기능은 동일하게 동작한다는 확신을 가질 수 있어.
  • 테스트 코드는 그 자체로 가장 정확하고 살아있는 문서가 돼. 새로운 동료가 프로젝트에 합류했을 때, 테스트 코드만 봐도 클래스의 역할과 기능을 빠르게 파악할 수 있지.
  • 결과적으로, 버그는 줄어들고 개발 속도는 오히려 빨라져. 디버깅에 쏟던 시간을 새로운 가치를 만드는 데 쓸 수 있게 되는 거야.

물론, 좋은 테스트를 작성하는 건 또 다른 차원의 기술이야. 어떤 것을 테스트해야 하는지, 어떻게 테스트해야 좋은지 고민하는 과정이 필요하지. 하지만 오늘 배운 JUnit의 기능들만 잘 활용해도 충분히 훌륭한 시작을 할 수 있어.

혹시 알아? 이렇게 테스트 코드 작성 능력을 갈고닦다 보면, 너의 뛰어난 개발 역량을 다른 사람들에게 알려줄 기회가 생길지도 몰라. 예를 들어 **재능넷** 같은 플랫폼에서 너의 테스트 코드 작성 노하우나 클린 코드 컨설팅 재능을 공유할 수도 있겠지. 개발 실력은 나눌수록 더 커지는 법이니까!

오늘 당장 너의 작은 프로젝트에, 혹은 지금 만들고 있는 기능에 간단한 `@Test` 메서드 하나를 추가해보는 건 어떨까?
그 작은 시작이 너를 더 나은 개발자, 자신감 넘치는 개발자로 만들어 줄 첫걸음이 될 거야.

Happy Coding & Happy Testing! 🚀

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.