자바 테스트 - JUnit 5 단위 통합 구분과 테스트하기 좋은 설계 (자바 고급 23단원)
이 단원에서 배우는 것
17단원부터 22단원까지 재고 예약, 비동기 주문 조회, 프록시, 성능 개선을 다뤘다. 전부 main 메서드에 넣고 눈으로 결과를 확인했다. 이 방식은 예제 하나짜리에서만 통한다. 22단원에서 List 를 Set 으로 바꾼 것 같은 변경을 실제 코드에 하려면, 바꾼 뒤에도 결과가 같다는 것을 증명할 수단이 필요하다. 그 수단이 없으면 아무도 리팩터링을 하지 않고, 코드는 굳는다. 이번 단원은 JUnit 5로 그 안전망을 만드는 방법과, 더 중요하게는 테스트하기 어려운 코드를 어떻게 알아보고 고치는지를 다룬다.
- JUnit 5의 핵심 기능(
assertThrows,@ParameterizedTest,@Nested)을 실제 주문 도메인에 적용한다 - 시간·랜덤·외부 호출 같은 "테스트를 어렵게 만드는 것"을 경계 밖으로 밀어내는 설계를 익힌다
- 단위 테스트와 통합 테스트를 비용 기준으로 나누고, 각각 무엇을 검증할지 정한다
왜 필요한가
테스트를 쓰는 이유로 흔히 "버그를 잡기 위해"라고 하는데, 정확하지 않다. 처음 작성한 코드의 버그는 대개 테스트를 쓰기 전에 발견된다. 테스트가 실제로 값을 하는 순간은 6개월 뒤 다른 사람이 그 코드를 고칠 때다. 할인 정책에 조건 하나를 추가했더니 기존 프로모션 계산이 깨지는 상황을, 배포 전에 알려 주는 것이 테스트다.
그리고 부수 효과가 하나 더 있다. 테스트가 잘 안 써지는 코드는 대개 설계가 나쁘다. 예를 들어 아래 메서드는 테스트를 쓸 수가 없다.
public long discount(Order order) {
long base = order.amount() >= 100_000 ? order.amount() / 10 : 0;
if (LocalDate.now().getDayOfWeek() == DayOfWeek.WEDNESDAY) { // 여기
base += order.amount() / 20;
}
return base;
}
수요일 할인을 검증하려면 수요일까지 기다려야 한다. 그래서 대부분 그 분기를 테스트하지 않고 넘어가고, 나중에 수요일에만 금액이 틀리는 장애가 난다. "테스트가 어렵다"는 신호는 대개 "의존성이 코드 안에 숨어 있다"는 뜻이다. 테스트는 그 신호를 읽는 도구이기도 하다.
문법과 예제
1. 테스트 가능한 구조로 먼저 바꾼다
앞의 LocalDate.now() 를 Clock 으로 밖에서 받는다. Clock 은 자바 표준이고, Clock.fixed(...) 로 원하는 시각에 고정할 수 있다.
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.LocalDate;
/** 시간 의존을 Clock 으로 밖에서 주입받는다 */
public class DiscountPolicy {
private final Clock clock;
public DiscountPolicy(Clock clock) { this.clock = clock; }
public long discount(Order order) {
long base = order.amount() >= 100_000 ? order.amount() / 10 : 0;
DayOfWeek day = LocalDate.now(clock).getDayOfWeek();
if (day == DayOfWeek.WEDNESDAY) base += order.amount() / 20; // 수요일 추가 5%
return base;
}
}
재고 확인도 마찬가지다. 실제 DB나 외부 시스템을 부르는 대신 인터페이스로 경계를 긋는다. 20단원에서 프록시가 인터페이스를 필요로 했던 것과 같은 구조다.
public interface InventoryPort {
boolean reserve(String sku, int qty);
}
public class Order {
private final String sku;
private final int qty;
private final long unitPrice;
public Order(String sku, int qty, long unitPrice) {
if (qty <= 0) throw new IllegalArgumentException("수량은 1 이상이어야 한다: " + qty);
this.sku = sku; this.qty = qty; this.unitPrice = unitPrice;
}
public long amount() { return qty * unitPrice; }
public String sku() { return sku; }
public int qty() { return qty; }
}
public class OrderService {
private final InventoryPort inventory;
private final DiscountPolicy policy;
public OrderService(InventoryPort inventory, DiscountPolicy policy) {
this.inventory = inventory; this.policy = policy;
}
public long place(Order order) {
if (!inventory.reserve(order.sku(), order.qty())) {
throw new IllegalStateException("재고 부족: " + order.sku());
}
return order.amount() - policy.discount(order);
}
}
여기서 생성자 주입이 핵심이다. 필드에 new 로 직접 만들면 테스트에서 바꿔 끼울 방법이 없다. 스프링을 쓰든 안 쓰든, 의존성을 생성자로 받는 습관 하나가 테스트 가능성의 8할이다.
2. JUnit 5 테스트 작성
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("주문 접수")
class OrderServiceTest {
private static final Clock 평일 =
Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); // 화요일
private static final Clock 수요일 =
Clock.fixed(Instant.parse("2026-08-26T00:00:00Z"), ZoneOffset.UTC);
private OrderService service(boolean 재고있음, Clock clock) {
InventoryPort stub = (sku, qty) -> 재고있음; // 인터페이스가 하나면 람다로 충분하다
return new OrderService(stub, new DiscountPolicy(clock));
}
@Test
@DisplayName("10만원 미만이면 할인이 없다")
void 할인없음() {
long paid = service(true, 평일).place(new Order("SKU-1", 1, 50_000));
assertEquals(50_000, paid);
}
@Test
@DisplayName("10만원 이상이면 10% 할인")
void 정률할인() {
long paid = service(true, 평일).place(new Order("SKU-1", 1, 100_000));
assertEquals(90_000, paid);
}
@Test
@DisplayName("수요일에는 5%가 추가된다")
void 수요일추가할인() {
long paid = service(true, 수요일).place(new Order("SKU-1", 1, 100_000));
assertEquals(85_000, paid);
}
@Test
@DisplayName("재고가 없으면 IllegalStateException 과 SKU 가 담긴 메시지")
void 재고부족() {
OrderService svc = service(false, 평일);
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> svc.place(new Order("SKU-9", 1, 1_000)));
assertTrue(e.getMessage().contains("SKU-9"));
}
@ParameterizedTest(name = "수량 {0} 이면 예외")
@CsvSource({"0", "-1", "-100"})
void 잘못된수량(int qty) {
assertThrows(IllegalArgumentException.class, () -> new Order("SKU-1", qty, 1_000));
}
@Nested
@DisplayName("금액 계산")
class 금액 {
@Test
void 수량만큼_곱한다() {
assertEquals(38_700, new Order("SKU-1", 3, 12_900).amount());
}
}
}
├─ JUnit Jupiter ✔
│ └─ 주문 접수 ✔
│ ├─ 수요일에는 5%가 추가된다 ✔
│ ├─ 잘못된수량(int) ✔
│ │ ├─ 수량 0 이면 예외 ✔
│ │ ├─ 수량 -1 이면 예외 ✔
│ │ └─ 수량 -100 이면 예외 ✔
│ ├─ 재고가 없으면 IllegalStateException 과 SKU 가 담긴 메시지 ✔
│ ├─ 10만원 이상이면 10% 할인 ✔
│ ├─ 10만원 미만이면 할인이 없다 ✔
│ └─ 금액 계산 ✔
│ └─ 수량만큼_곱한다() ✔
[ 8 tests successful ]
JUnit 4에서 넘어온 사람이 헷갈리는 지점만 짚는다.
| JUnit 4 | JUnit 5 (Jupiter) | 비고 |
|---|---|---|
@Before / @After | @BeforeEach / @AfterEach | 이름이 명확해졌다 |
@BeforeClass | @BeforeAll | static 필요(인스턴스 생명주기를 바꾸면 예외) |
@Test(expected=...) | assertThrows(...) | 예외 발생 위치까지 특정할 수 있다 |
@Ignore | @Disabled("사유") | 사유를 적게 되어 있다 |
테스트 클래스 public | 패키지-프라이빗 가능 | 메서드도 마찬가지 |
assertThrows 가 @Test(expected=...) 보다 나은 이유는 분명하다. 후자는 메서드 어디서든 그 예외가 나면 통과한다. 준비 단계에서 실수로 같은 예외가 나도 초록불이 켜진다. assertThrows 는 람다로 감싼 그 호출에서만 검증하고, 잡은 예외 객체를 돌려주므로 메시지까지 확인할 수 있다.
여러 값을 함께 검증할 때는 assertAll 을 쓴다. assertEquals 를 연달아 쓰면 첫 실패에서 멈춰 나머지 정보를 잃는다.
assertAll("주문 금액",
() -> assertEquals("SKU-1", order.sku()),
() -> assertEquals(3, order.qty()),
() -> assertEquals(38_700, order.amount()));
3. 단위 테스트와 통합 테스트를 나누는 기준
기준은 "무엇을 검증하느냐"가 아니라 "무엇을 실제로 띄우느냐" 다.
| 구분 | 띄우는 것 | 한 건당 시간 | 검증 대상 |
|---|---|---|---|
| 단위 | 없음. 객체만 new | 수 ms | 계산 규칙, 분기, 경계값, 예외 |
| 슬라이스 | 웹 계층 또는 JPA 계층만 | 수백 ms | 직렬화, 검증 애노테이션, 쿼리 매핑 |
| 통합 | 스프링 컨텍스트 + DB | 수 초 | 트랜잭션 경계, 실제 SQL, 설정 결합 |
수가 많아야 하는 것은 위쪽이다. 할인 정책의 경계값 20가지를 통합 테스트로 만들면 빌드가 몇 분씩 걸리고, 결국 아무도 안 돌리게 된다. 규칙은 단위 테스트로 촘촘하게, 연결은 통합 테스트로 대표 경로만 검증한다. 위 예제의 OrderServiceTest 8개가 76ms에 끝났다는 점을 기억하자. 이 속도가 유지되어야 저장할 때마다 돌릴 수 있다.
DB가 필요한 통합 테스트는 인메모리 H2 대신 Testcontainers로 실제 DB 이미지를 띄우는 것이 요즘 표준이다. H2는 방언이 달라서, 실제 MariaDB에서만 실패하는 SQL을 통과시킨다. 통합 테스트가 실제와 다르면 있으나 마나다.
실무에서 자주 틀리는 것
1. 테스트끼리 상태를 공유한다
static 필드나 @BeforeAll 에서 만든 컬렉션을 여러 테스트가 함께 쓰면, 실행 순서에 따라 결과가 달라진다. JUnit 5는 실행 순서를 보장하지 않는다. 로컬에서는 통과하는데 CI에서만 깨지는 테스트의 흔한 원인이다. 각 테스트는 @BeforeEach 에서 자기 상태를 새로 만들어야 한다. @TestMethodOrder 로 순서를 고정해 해결하려는 시도를 종종 보는데, 이건 문제를 감추는 것이다. 순서에 의존해야 한다면 그건 테스트 하나로 합쳐야 할 시나리오다.
2. 목(mock)으로 구현을 검증한다
// 나쁜 예: 내부 호출 순서를 검증한다
verify(inventory, times(1)).reserve("SKU-1", 3);
verify(policy, times(1)).discount(any());
InOrder o = inOrder(inventory, policy);
이렇게 쓰면 리팩터링할 때마다 테스트가 깨진다. discount 를 두 번 호출하도록 코드를 정리하면 동작이 같은데도 테스트가 실패한다. 테스트는 결과를 검증해야지 과정을 검증하면 안 된다. 호출 검증이 정당한 경우는 "결과로 관찰할 수 없는 부수 효과"뿐이다. 메일 발송, 이벤트 발행 같은 것들이다. 위 예제에서 목 프레임워크 없이 람다 스텁만으로 충분했던 것도 같은 맥락이다. 인터페이스가 작으면 목이 필요 없다.
3. 테스트에서 sleep 으로 비동기를 기다린다
17~18단원의 비동기 코드를 테스트할 때 Thread.sleep(500) 을 넣으면, CI 서버가 느린 날 실패한다. 반대로 넉넉하게 3초를 주면 테스트가 느려진다. CountDownLatch 로 완료를 기다리거나, CompletableFuture 라면 get(2, TimeUnit.SECONDS) 로 결과를 직접 받는다. 더 나은 방법은 비동기 실행 자체를 주입 가능하게 만드는 것이다. 테스트에서는 같은 스레드에서 즉시 실행하는 Executor(Runnable::run)를 넣으면 비동기 요소가 통째로 사라져 검증이 단순해진다.
4. private 메서드를 리플렉션으로 테스트한다
20단원에서 예고한 이야기다. setAccessible(true) 로 private 메서드를 꺼내 검증하면, 이름만 바꿔도 테스트가 런타임에 깨지고 컴파일러는 못 잡는다. private 메서드가 따로 검증할 만큼 복잡하다면, 그건 별도 클래스로 나올 때가 된 로직이라는 신호다. 위 예제에서 할인 계산을 DiscountPolicy 로 뽑아낸 것이 그 처방이다. 뽑아내고 나면 그냥 public 메서드를 테스트하면 된다.
5. 트랜잭션 롤백 테스트를 실제 동작이라 믿는다
스프링에서 @Transactional 을 테스트에 붙이면 끝나고 자동 롤백되어 편리하다. 그런데 이때 테스트 메서드 전체가 하나의 트랜잭션 안에서 돌기 때문에, 실제 운영에서와 커밋 시점이 달라진다. 특히 JPA에서 플러시가 일어나지 않아 제약 조건 위반이 테스트에서만 안 잡히는 일이 흔하다. 컬럼 길이 초과나 NOT NULL 위반이 운영 배포 후에야 터진다. 저장 로직을 검증할 때는 flush() 를 명시적으로 호출하거나, 중요한 경로는 롤백 없이 검증하고 데이터를 직접 정리한다.
스스로 확인하기
- 주문번호를
"ORD-" + UUID.randomUUID()로 만드는 메서드가 있다. 형식이 맞는지 테스트하려 한다. 무엇이 문제이고 어떻게 바꾸는가? - 다음 테스트는 무엇을 잘못하고 있는가?
@Test void 주문접수() { OrderService svc = new OrderService(realInventory, realPolicy); long paid = svc.place(new Order("SKU-1", 1, 100_000)); assertTrue(paid > 0); } - 단위 테스트 400개(총 3초)와 통합 테스트 40개(총 4분)가 있다. CI에서 어떻게 배치하는 것이 좋은가?
정답
- 랜덤 값이 코드 안에 숨어 있어 결과를 예측할 수 없다. 형식만 정규식으로 확인하는 테스트는 쓸 수 있지만, "같은 입력에 같은 주문번호"처럼 의미 있는 검증은 불가능하다.
Clock을 주입한 것과 같은 방식으로Supplier<String> idGenerator를 생성자로 받게 바꾼다. 운영에서는() -> UUID.randomUUID().toString()을, 테스트에서는() -> "FIXED-1"을 넣는다. 시간·랜덤·현재 사용자·환경변수는 전부 같은 처방이다. 코드 안에서 직접 얻지 말고 밖에서 받는다. - 세 가지다. (a) 실제 구현(
realInventory)을 그대로 써서 DB나 외부 시스템 상태에 결과가 좌우된다. 재고가 없는 날이면 테스트가 실패한다. (b)assertTrue(paid > 0)는 사실상 아무것도 검증하지 않는다. 할인이 잘못 계산돼 89,999원이 나와도 통과한다. 기대값90_000을 정확히 명시해야 한다. (c) 실패했을 때 메시지가expected true but was false뿐이라 원인을 알 수 없다.assertEquals는 기대값과 실제값을 모두 보여 준다. 정리하면 스텁으로 의존성을 고정하고, 정확한 기대값을 쓴다. - 두 단계로 나눈다. 커밋마다 도는 단계에서는 단위 테스트 400개만 돌린다. 3초면 개발자가 결과를 기다릴 수 있고, 피드백이 빠를수록 고치는 비용이 싸다. 통합 테스트 40개는 PR 병합 전이나 머지 후 파이프라인에서 돌린다. 메이븐이라면 Surefire(단위)와 Failsafe(통합,
*IT.java)로, JUnit 5의@Tag("integration")과 태그 필터로도 나눌 수 있다. 중요한 것은 느린 테스트가 빠른 테스트의 발목을 잡지 않게 하는 것이다. 전체를 한 덩어리로 두면 결국 아무도 로컬에서 테스트를 돌리지 않게 된다.