코드가 정상 동작한다는 걸 어떻게 보장할 수 있을까? System.out.println으로 눈 확인하는 건 클래스가 수십 개로 늘어나면 한계가 온다. 자동화된 테스트가 있으면 코드를 변경할 때마다 수백 개의 시나리오를 즉시 검증할 수 있다.

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.


1. 테스트를 왜 작성하나

"돌아가는데 테스트까지 써야 해요?"라는 질문을 한 번쯤은 해봤을 거예요. 테스트 코드를 작성하는 이유는 크게 세 가지입니다.

버그 방지

코드를 변경할 때마다 손으로 확인하기엔 경우의 수가 너무 많아요. 자동화된 테스트가 있으면 npm test(혹은 ./gradlew test) 한 번에 수백 개의 시나리오를 검증할 수 있습니다.

리팩토링 안전망

"이 메서드 이름 바꿔도 괜찮을까?" — 테스트가 통과하면 괜찮습니다. 테스트는 코드를 고칠 때 ** 깨진 부분을 즉시 알려주는 안전망** 역할을 해요.

살아 있는 문서

잘 작성된 테스트 코드는 "이 클래스를 어떻게 쓰라는 거지?"에 대한 가장 정확한 예제입니다. 주석이나 README보다 신뢰도가 높아요. 왜냐하면 ** 테스트는 거짓말을 하면 빨간불이 켜지니까요.**


2. JUnit 5 기본 — @Test와 Assertions

JUnit 5는 자바 테스트의 사실상 표준입니다. JUnit Platform + JUnit Jupiter + JUnit Vintage 세 모듈로 구성되지만, 대부분의 경우 Jupiter(테스트 작성 API)만 알면 돼요.

의존성 추가 (Gradle)

GROOVY
// build.gradle
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}

test {
    useJUnitPlatform() // JUnit 5 플랫폼 사용
}

첫 번째 테스트

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

class CalculatorTest {

    @Test
    void 더하기_테스트() {
        // given - 준비
        Calculator calc = new Calculator();

        // when - 실행
        int result = calc.add(2, 3);

        // then - 검증
        assertEquals(5, result);
    }
}
  • @Test를 붙이면 JUnit이 해당 메서드를 테스트로 인식합니다.
  • 메서드 이름은 한글로 써도 됩니다. 오히려 테스트 목적이 명확해져요.
  • assertEquals(기대값, 실제값) — 두 값이 같은지 비교합니다.

주요 Assertion 메서드

JAVA
// 값 비교
assertEquals(5, result);                    // 같은지
assertNotEquals(0, result);                 // 다른지

// 참/거짓
assertTrue(list.isEmpty());                 // 참인지
assertFalse(user.isBlocked());              // 거짓인지

// null 검사
assertNull(deletedUser);                    // null인지
assertNotNull(savedUser);                   // null이 아닌지

// 예외 검증
assertThrows(IllegalArgumentException.class, () -> {
    calc.divide(10, 0); // 0으로 나누면 예외 발생
});

// 예외 메시지까지 검증
Exception ex = assertThrows(IllegalArgumentException.class, () -> {
    calc.divide(10, 0);
});
assertEquals("0으로 나눌 수 없습니다", ex.getMessage());

// 여러 검증을 한 번에 — 하나가 실패해도 나머지를 계속 실행
assertAll(
    () -> assertEquals("홍길동", user.getName()),
    () -> assertEquals(25, user.getAge()),
    () -> assertNotNull(user.getEmail())
);

assertAll은 일반적인 assertion과 동작이 다릅니다. 첫 번째 assertion이 실패해도 나머지를 계속 실행하므로, 모든 검증 결과를 한 번에 볼 수 있어요. 여러 필드를 동시에 검증할 때 유용합니다.


3. 생명주기 — @BeforeEach, @AfterEach, @BeforeAll, @AfterAll

테스트 메서드마다 반복되는 준비/정리 작업이 있으면 생명주기 어노테이션을 사용합니다.

JAVA
class UserServiceTest {

    private UserService service;
    private UserRepository repo;

    @BeforeAll
    static void 전체_초기화() {
        // 모든 테스트 전에 한 번만 실행
        // static 메서드여야 한다
        System.out.println("테스트 시작");
    }

    @BeforeEach
    void 각_테스트_전() {
        // 각 테스트 메서드 실행 전마다 호출
        repo = new FakeUserRepository();
        service = new UserService(repo);
    }

    @AfterEach
    void 각_테스트_후() {
        // 각 테스트 메서드 실행 후마다 호출
        // 리소스 정리 등
    }

    @AfterAll
    static void 전체_정리() {
        // 모든 테스트 후에 한 번만 실행
        System.out.println("테스트 종료");
    }

    @Test
    void 사용자_생성() {
        // service와 repo는 @BeforeEach에서 이미 준비됨
        User user = service.create("홍길동");
        assertNotNull(user.getId());
    }
}

실행 순서

PLAINTEXT
@BeforeAll (한 번)
  ├─ @BeforeEach → @Test 메서드 1 → @AfterEach
  ├─ @BeforeEach → @Test 메서드 2 → @AfterEach
  └─ @BeforeEach → @Test 메서드 3 → @AfterEach
@AfterAll (한 번)

핵심 포인트:

  • JUnit 5는 각 테스트 메서드마다 새 인스턴스를 생성 합니다. 그래서 @BeforeAll/@AfterAll은 기본적으로 static이어야 해요.
  • @BeforeEach에서 매번 새 객체를 만들면 테스트 간 상태가 격리 됩니다. 이게 정말 중요해요.

4. @DisplayName과 @Nested — 테스트 가독성 높이기

테스트가 많아지면 결과를 읽기가 힘들어집니다. JUnit 5는 이를 위한 도구를 제공해요.

@DisplayName

JAVA
@DisplayName("Calculator 테스트")
class CalculatorTest {

    @Test
    @DisplayName("양수 두 개를 더하면 합을 반환한다")
    void addPositiveNumbers() {
        assertEquals(5, new Calculator().add(2, 3));
    }

    @Test
    @DisplayName("0으로 나누면 IllegalArgumentException이 발생한다")
    void divideByZero() {
        assertThrows(IllegalArgumentException.class,
            () -> new Calculator().divide(10, 0));
    }
}

테스트 결과에 addPositiveNumbers 대신 "양수 두 개를 더하면 합을 반환한다" 가 표시됩니다. IDE에서 한눈에 파악할 수 있어요.

@Nested — 테스트를 계층 구조로

JAVA
@DisplayName("OrderService")
class OrderServiceTest {

    @Nested
    @DisplayName("주문 생성 시")
    class CreateOrder {

        @Test
        @DisplayName("재고가 충분하면 주문이 성공한다")
        void 재고_충분() {
            // ...
        }

        @Test
        @DisplayName("재고가 부족하면 예외가 발생한다")
        void 재고_부족() {
            // ...
        }
    }

    @Nested
    @DisplayName("주문 취소 시")
    class CancelOrder {

        @Test
        @DisplayName("배송 전이면 취소가 가능하다")
        void 배송_전_취소() {
            // ...
        }

        @Test
        @DisplayName("배송 후에는 취소가 불가능하다")
        void 배송_후_취소() {
            // ...
        }
    }
}

실행 결과가 트리 구조로 보인다:

PLAINTEXT
OrderService
├── 주문 생성 시
│   ├── ✅ 재고가 충분하면 주문이 성공한다
│   └── ✅ 재고가 부족하면 예외가 발생한다
└── 주문 취소 시
    ├── ✅ 배송 전이면 취소가 가능하다
    └── ✅ 배송 후에는 취소가 불가능하다

@Nested로 시나리오별 그룹핑을 하면 테스트가 많아져도 구조가 명확하게 유지됩니다.


5. @ParameterizedTest — 여러 입력값으로 반복 테스트

같은 로직을 다른 입력값으로 여러 번 테스트하고 싶을 때 사용합니다. @Test 대신 @ParameterizedTest를 쓰면 돼요.

@ValueSource — 단일 값 목록

JAVA
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void 양수_판별(int number) {
    assertTrue(number > 0);
}

@CsvSource — 입력과 기대값 쌍

JAVA
@ParameterizedTest
@CsvSource({
    "1, 2, 3",    // 1 + 2 = 3
    "10, 20, 30", // 10 + 20 = 30
    "-1, 1, 0"    // -1 + 1 = 0
})
void 덧셈_테스트(int a, int b, int expected) {
    assertEquals(expected, new Calculator().add(a, b));
}

@MethodSource — 복잡한 데이터는 메서드로

JAVA
@ParameterizedTest
@MethodSource("userProvider")
void 사용자_이름_검증(String name, boolean expected) {
    assertEquals(expected, UserValidator.isValidName(name));
}

// 테스트 데이터를 제공하는 정적 메서드
static Stream<Arguments> userProvider() {
    return Stream.of(
        Arguments.of("홍길동", true),       // 정상 이름
        Arguments.of("", false),            // 빈 문자열
        Arguments.of(null, false),          // null
        Arguments.of("a".repeat(51), false) // 50자 초과
    );
}

@ParameterizedTest를 쓰면 테스트 메서드 하나로 수십 가지 케이스를 커버할 수 있습니다. 경계값 테스트에 특히 유용해요. 참고로 @NullAndEmptySource를 함께 쓰면 null과 빈 문자열도 자동으로 테스트할 수 있습니다.


6. Mockito 기본 — @Mock, @InjectMocks, when/thenReturn, verify

실제 서비스를 테스트할 때 문제가 하나 있습니다. UserService를 테스트하고 싶은데, 내부에서 UserRepository(DB 접근)를 쓰거든요. 테스트할 때마다 DB를 띄울 수는 없잖아요.

이럴 때 Mockito 로 가짜 객체(Mock)를 만듭니다.

의존성 추가

GROOVY
dependencies {
    testImplementation 'org.mockito:mockito-core:5.11.0'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0'
}

기본 사용법

JAVA
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 static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class) // Mockito 활성화
class UserServiceTest {

    @Mock
    UserRepository userRepository; // 가짜 객체 생성

    @InjectMocks
    UserService userService; // Mock을 주입한 실제 객체

    @Test
    void 사용자_조회_성공() {
        // given - Mock 행동 정의 (스텁)
        User fakeUser = new User(1L, "홍길동");
        when(userRepository.findById(1L)).thenReturn(Optional.of(fakeUser));

        // when - 테스트 대상 실행
        User result = userService.findById(1L);

        // then - 검증
        assertEquals("홍길동", result.getName());
        verify(userRepository).findById(1L); // findById가 1번 호출되었는지 검증
    }

    @Test
    void 존재하지_않는_사용자_조회() {
        // given
        when(userRepository.findById(999L)).thenReturn(Optional.empty());

        // when & then
        assertThrows(UserNotFoundException.class,
            () -> userService.findById(999L));
    }
}

핵심 개념 정리

Mockito 기능설명
@Mock가짜 객체 생성. 모든 메서드가 기본값(null, 0, false) 반환
@InjectMocks@Mock으로 만든 객체를 자동으로 주입
when().thenReturn()"이 메서드가 호출되면 이 값을 반환해라" (스텁 설정)
when().thenThrow()"이 메서드가 호출되면 이 예외를 던져라"
verify()특정 메서드가 호출되었는지 검증
verify(mock, times(2))정확히 2번 호출되었는지 검증
verify(mock, never())한 번도 호출되지 않았는지 검증

verify는 "이 동작이 정말 일어났는가?"를 확인할 때 씁니다. when/thenReturn입력 → 출력 을 제어하는 거라면, verify부수 효과(side effect) 를 검증하는 역할이에요. 예를 들어 회원가입 시 이메일 발송이 호출되었는지 verify(emailService).sendWelcomeEmail(anyString())처럼 확인할 수 있습니다.


7. 테스트 더블 — Stub, Mock, Spy의 차이

"가짜 객체"를 통칭해서 테스트 더블(Test Double) 이라고 부릅니다. 영화에서 배우 대신 위험한 장면을 찍는 스턴트 더블에서 따온 용어예요. 종류가 여러 가지 있는데, 자주 물어보는 세 가지만 짚어볼게요.

Stub (스텁)

  • 미리 준비된 답을 반환 하는 객체
  • "이걸 물어보면 이걸 답해" 수준
  • Mockito의 when().thenReturn()이 스텁 설정
JAVA
// "findById(1L)이 호출되면 fakeUser를 반환해라" → 이게 스텁
when(userRepository.findById(1L)).thenReturn(Optional.of(fakeUser));

Mock (모의 객체)

  • 스텁 기능 + 호출 여부를 검증 할 수 있는 객체
  • "이 메서드가 호출되었는지"까지 확인 가능
  • Mockito의 verify()를 쓰면 Mock으로 쓰는 것
JAVA
// 스텁 설정 (Stub 역할)
when(userRepository.save(any())).thenReturn(savedUser);

// 호출 검증 (Mock 역할)
verify(userRepository).save(any());

Spy (스파이)

  • 실제 객체를 감싸서 일부 동작만 가로채는 객체
  • 기본적으로 실제 메서드가 호출되고, 원하는 메서드만 오버라이드
JAVA
@Spy
List<String> spyList = new ArrayList<>();

@Test
void spy_테스트() {
    spyList.add("one");     // 실제 add() 호출
    spyList.add("two");     // 실제 add() 호출

    assertEquals(2, spyList.size()); // 실제 크기: 2

    // 특정 메서드만 스텁
    doReturn(100).when(spyList).size();
    assertEquals(100, spyList.size()); // 스텁된 값: 100
}

한눈에 비교

종류실제 동작반환값 제어호출 검증
StubXOX
MockXOO
SpyO (기본)O (부분 오버라이드)O

핵심 구분: Mock은 ** 행위 검증 **(behavior verification)에, Stub은 ** 상태 검증 **(state verification)에 초점을 맞춥니다. Martin Fowler의 Mocks Aren't Stubs 글에서 정리된 개념이에요.


8. given-when-then 패턴 — BDD 스타일 테스트 구조화

테스트 코드가 길어지면 "이 줄이 뭘 하는 거지?" 하고 헤매기 쉽습니다. given-when-then 패턴으로 구조를 잡으면 가독성이 확 올라가요.

JAVA
@Test
@DisplayName("잔액이 충분하면 출금에 성공한다")
void 출금_성공() {
    // given — 사전 조건, 테스트에 필요한 데이터 준비
    Account account = new Account(10000);

    // when — 테스트 대상 동작 실행
    account.withdraw(3000);

    // then — 결과 검증
    assertEquals(7000, account.getBalance());
}

세 단계의 의미

단계의미예시
given어떤 상황이 주어졌을 때잔액 10,000원 계좌
when어떤 동작을 하면3,000원 출금
then어떤 결과가 나와야 한다잔액이 7,000원

Mockito BDD 스타일

Mockito는 BDD 스타일을 위한 별도 API도 제공합니다:

JAVA
import static org.mockito.BDDMockito.*;

@Test
@DisplayName("주문 생성 시 재고가 차감된다")
void 주문_재고_차감() {
    // given
    Product product = new Product(1L, "노트북", 10);
    given(productRepository.findById(1L))      // when 대신 given 사용
        .willReturn(Optional.of(product));      // thenReturn 대신 willReturn

    // when
    orderService.createOrder(1L, 2);

    // then
    then(productRepository).should().save(any(Product.class)); // verify 대신 then/should
    assertEquals(8, product.getStock());
}

when/thenReturngiven/willReturn, verifythen/should. 의미적으로 given-when-then 흐름에 더 자연스럽게 녹아듭니다. 하지만 기존 스타일과 혼용하는 팀도 많으니, 팀 컨벤션을 따르면 돼요.


9. 테스트 커버리지 — JaCoCo와 100%의 함정

JaCoCo란?

JaCoCo(Java Code Coverage) 는 테스트가 소스 코드의 얼마나 많은 부분을 실행했는지 측정하는 도구입니다.

GROOVY
// build.gradle
plugins {
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.12"
}

jacocoTestReport {
    reports {
        html.required = true // HTML 리포트 생성
    }
}

// 최소 커버리지 설정 (선택)
jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80 // 80% 이상 커버리지 필수
            }
        }
    }
}

./gradlew test jacocoTestReport를 실행하면 build/reports/jacoco/ 폴더에 HTML 리포트가 생깁니다.

커버리지의 종류

종류측정 대상설명
라인 커버리지코드 줄각 줄이 한 번 이상 실행되었는지
** 브랜치 커버리지**if/else 분기모든 분기가 실행되었는지
** 메서드 커버리지**메서드각 메서드가 호출되었는지

100% 커버리지의 함정

커버리지 100%를 달성해도 버그 없음을 보장하지 않습니다. 이유는 간단해요:

JAVA
// 이 테스트는 커버리지를 올리지만, 의미 있는 검증을 하지 않는다
@Test
void 의미없는_테스트() {
    UserService service = new UserService(mockRepo);
    service.findById(1L); // 호출만 하고 결과는 안 본다
}

모든 줄을 "실행"만 해도 커버리지는 올라갑니다. 하지만 ** 결과를 검증하지 않으면** 의미가 없어요.

현실적인 가이드라인:

  • 70~80% 정도를 팀 목표로 잡는 경우가 많습니다
  • 비즈니스 핵심 로직은 90% 이상 을 노리되, getter/setter 같은 단순 코드는 무리하게 테스트하지 않아요
  • 커버리지보다 중요한 건 의미 있는 테스트를 작성하는 것 입니다
  • 경계값(0, null, 빈 문자열, 최대값)을 빠뜨리지 않았는지가 더 중요해요

10. TDD 간단 소개 — Red-Green-Refactor

TDD(Test-Driven Development)는 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 나중에 구현 하는 방법론입니다.

Red-Green-Refactor 사이클

PLAINTEXT
     ┌─────────────────────┐
     │                     │
     ▼                     │
  🔴 Red                   │
  실패하는 테스트 작성       │
     │                     │
     ▼                     │
  🟢 Green                 │
  테스트를 통과하는          │
  최소한의 코드 작성         │
     │                     │
     ▼                     │
  🔵 Refactor              │
  코드를 깔끔하게 정리       │
     │                     │
     └─────────────────────┘

예시: 문자열 뒤집기

Step 1 — Red: 실패하는 테스트 먼저 작성

JAVA
@Test
void 문자열_뒤집기() {
    StringUtils utils = new StringUtils();
    assertEquals("cba", utils.reverse("abc"));
    // StringUtils 클래스도 없고, reverse 메서드도 없다 → 컴파일 에러 (Red)
}

Step 2 — Green: 테스트를 통과하는 최소 코드 작성

JAVA
class StringUtils {
    String reverse(String input) {
        return new StringBuilder(input).reverse().toString();
    }
}
// 테스트 통과! (Green)

Step 3 — Refactor: null 처리, 빈 문자열 처리 등 개선. 그에 맞는 테스트(null_입력시_null_반환, 빈_문자열_입력시_빈_문자열_반환)도 추가합니다.

JAVA
class StringUtils {
    String reverse(String input) {
        if (input == null || input.isEmpty()) {
            return input; // null이나 빈 문자열은 그대로 반환
        }
        return new StringBuilder(input).reverse().toString();
    }
}

TDD의 장단점

장점단점
설계를 먼저 생각하게 된다초기 개발 속도가 느려질 수 있다
자연스럽게 테스트 가능한 코드가 나온다익숙해지기까지 학습 비용이 있다
리팩토링이 안전하다요구사항이 자주 바뀌면 테스트도 같이 바꿔야 한다
과도한 구현(over-engineering) 방지팀 전체가 동의해야 효과적이다

솔직히 말하면, 실무에서 100% TDD를 하는 팀은 많지 않습니다. 하지만 TDD의 사이클을 이해하고 있으면 "테스트를 어떻게 짜야 할지 모르겠다"는 상황이 줄어들어요. ** 복잡한 비즈니스 로직이나 버그 수정 시 부분적으로 TDD를 적용 **하는 것이 현실적인 접근입니다.


11. 정리 테이블

JUnit 5 핵심 어노테이션

어노테이션용도
@Test테스트 메서드 지정
@DisplayName테스트 이름을 읽기 좋게 표시
@Nested테스트 클래스를 계층 구조로
@BeforeEach / @AfterEach각 테스트 전/후 실행
@BeforeAll / @AfterAll모든 테스트 전/후 한 번 실행 (static)
@ParameterizedTest여러 입력값으로 반복 테스트
@ValueSource단일 값 목록 제공
@CsvSourceCSV 형식으로 입력-기대값 쌍 제공
@MethodSource메서드로 복잡한 테스트 데이터 제공
@Disabled테스트 비활성화 (건너뛰기)

Mockito 핵심 정리

기능코드설명
Mock 생성@Mock가짜 객체 생성
Mock 주입@InjectMocksMock을 자동 주입
스텁 설정when().thenReturn()반환값 지정
예외 스텁when().thenThrow()예외 발생 지정
호출 검증verify(mock).method()호출 여부 확인
인자 매처any(), eq(value)유연한 인자 매칭

테스트 더블 비교

종류핵심언제 쓰나
Stub정해진 답 반환외부 의존성의 반환값을 고정하고 싶을 때
Mock답 반환 + 호출 검증메서드 호출 여부까지 확인하고 싶을 때
Spy실제 동작 + 부분 오버라이드실제 객체 대부분의 동작을 유지하되 일부만 바꿀 때

주의할 점

Mock 남용은 테스트를 깨지기 쉽게 만듭니다

모든 의존성을 Mock으로 만들면, 테스트가 ** 구현 세부사항에 결합 **됩니다. 메서드 호출 순서나 내부 로직이 바뀌면 기능은 정상인데 테스트가 깨져요. 가능하면 Fake 객체(간단한 인메모리 구현)를 쓰고, Mock은 외부 시스템(DB, HTTP) 접점에만 사용하는 것이 좋습니다.

@BeforeEach에서 매번 새 객체를 만들어야 합니다

JUnit 5는 각 테스트 메서드마다 새 테스트 인스턴스를 생성하지만, @BeforeEach에서 테스트 대상 객체를 새로 만들지 않으면 ** 이전 테스트의 상태가 남아 있을 수 있습니다.** 테스트 간 격리가 안 되면 실행 순서에 따라 결과가 달라지는 문제가 발생해요.

커버리지 100%는 버그 없음을 보장하지 않습니다

모든 코드 줄을 "실행"만 해도 커버리지는 올라갑니다. 하지만 결과를 검증하지 않는 테스트는 의미가 없어요. 경계값(0, null, 빈 문자열, 최대값) 테스트가 빠져 있으면 커버리지 수치와 무관하게 버그가 숨어 있을 수 있습니다.

정리

개념핵심 정리
JUnit 5@Test, assertEquals, assertAll, 생명주기 어노테이션으로 테스트 구조화
@ParameterizedTest같은 로직을 다른 입력으로 반복. @CsvSource, @MethodSource로 데이터 제공
Mockito@Mock으로 가짜 객체 생성, when/thenReturn으로 스텁, verify로 호출 검증
** 테스트 더블**Stub(정해진 답), Mock(답+호출 검증), Spy(실제 동작+부분 오버라이드)
given-when-then준비-실행-검증 구조. 주석으로라도 구분하면 가독성이 올라간다
JaCoCo라인/브랜치/메서드 커버리지 측정. 숫자보다 의미 있는 테스트가 중요하다
TDDRed-Green-Refactor 사이클. 복잡한 비즈니스 로직에 부분 적용부터 시작
댓글 로딩 중...