단위 테스트 파헤치기
지난 글에서는 테스트가 귀찮지만 왜 필요한지에 대해 알아봤다. 이번에는 '단위 테스트'에 대해 좀 더 깊게 들어가 보자.
단위 테스트(Unit Test)란?
말 그대로 작은 코드 단위를 독립적으로 검증하는 테스트다. 보통 클래스나 메서드 하나가 그 대상이 된다. 다른 테스트에 비해 검증 속도가 매우 빠르고 안정적이라는 장점이 있다.
Java에서는 보통 JUnit5라는 테스트 프레임워크를 사용한다. 여기에 AssertJ라는 라이브러리를 곁들이면, 풍부한 API와 메서드 체이닝을 통해 테스트 코드를 훨씬 깔끔하고 읽기 쉽게 작성할 수 있다.
// JUnit5의 @Test 어노테이션과 AssertJ의 assertThat을 사용한 예시
class CafeKioskTest {
@Test
void add() {
CafeKiosk cafeKiosk = new CafeKiosk();
cafeKiosk.add(new Americano());
// AssertJ를 사용하면 "cafeKiosk의 음료 리스트는 사이즈가 1이다" 라고
// 문장처럼 읽히는 코드를 작성할 수 있다.
assertThat(cafeKiosk.getBeverages()).hasSize(1);
assertThat(cafeKiosk.getBeverages().getFirst().getName()).isEqualTo("아메리카노");
}
@Test
void clear() {
CafeKiosk cafeKiosk = new CafeKiosk();
cafeKiosk.add(new Americano());
cafeKiosk.add(new Latte());
cafeKiosk.clear();
assertThat(cafeKiosk.getBeverages()).isEmpty();
}
}
테스트 케이스, 어떻게 더 잘게 쪼갤까?
좋은 테스트를 작성하려면 케이스를 잘 나누는 것이 중요하다. 이때 스스로에게 질문을 던지는 습관을 들이면 좋다.
"혹시 암묵적이거나 아직 드러나지 않은 요구사항이 있는가?"
예를 들어, "한 종류의 음료를 여러 잔 담는 기능"이 추가되었다고 해보자. 여기서 이런 질문을 던질 수 있다.
"만약 주문 수량(count)으로 0이 들어오면 어떻게 해야 하지?"
이렇게 질문을 통해 시나리오를 구체화하고 나면, 테스트 케이스는 자연스럽게 해피 케이스와 예외 케이스로 나뉜다. 특히 경계값을 테스트하는 것이 중요하다. (ex. 3 이상 주문 가능 -> 해피: 3, 예외: 2)
해피 케이스: 정상적으로 여러 잔이 담기는 경우
@Test
void addSeveralBeverages() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano, 2); // 2잔 추가
assertThat(cafeKiosk.getBeverages()).hasSize(2);
assertThat(cafeKiosk.getBeverages().getFirst()).isEqualTo(americano);
assertThat(cafeKiosk.getBeverages().get(1)).isEqualTo(americano);
}
예외 케이스: 0잔을 담으려고 시도하는 경우
@Test
void addZeroBeverages() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
// 0잔을 추가하면 IllegalArgumentException이 터지는 것을 기대한다.
assertThatThrownBy(() -> cafeKiosk.add(americano, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("음료는 1잔 이상 주문하실 수 있습니다.");
}
테스트하기 어려운 영역 분리하기
이런 요구사항이 있다고 해보자. "가게 운영 시간(10시 ~ 22시) 외에는 주문을 생성할 수 없다."
이걸 코드로 구현하면 아마 LocalDateTime.now()
같은 코드가 들어가게 될 것이다.
// 테스트하기 어려운 코드
public Order createOrder() {
LocalDateTime currentDateTime = LocalDateTime.now(); // 바로 이 부분!
LocalTime currentTime = currentDateTime.toLocalTime();
if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(currentDateTime, beverages);
}
LocalDateTime.now()
는 실행하는 시점마다 값이 바뀌기 때문에 테스트하기가 극도로 까다롭다. 새벽에 이 테스트를 실행하면 항상 실패할 것이다.
이럴 때 우리는 테스트하기 어려운 영역을 외부에서 제어할 수 있도록 분리해야 한다.
// 리팩토링 후 테스트하기 쉬워진 코드
public Order createOrder(LocalDateTime currentDateTime) { // 시간을 파라미터로 받는다!
LocalTime currentTime = currentDateTime.toLocalTime();
if (currentTime.isBefore(SHOP_OPEN_TIME) || currentTime.isAfter(SHOP_CLOSE_TIME)) {
throw new IllegalArgumentException("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
return new Order(currentDateTime, beverages);
}
우리가 진짜 검증하고 싶은 것은 LocalDateTime.now()
의 정확성이 아니다. "특정 시간이 주어졌을 때, 우리 로직이 올바르게 동작하는가?" 이다. 이렇게 외부에서 값을 주입할 수 있도록 구조를 변경하면, 원하는 어떤 시간이든 넣어서 테스트할 수 있게 된다.
테스트하기 어려운 영역이란?
- 관측할 때마다 다른 값에 의존하는 코드
- 현재 시간, 랜덤 값, 사용자 입력 등
- 외부 세계에 영향을 주는 코드
- DB에 기록, 메시지 발송, 파일 시스템 접근 등
결국 테스트하기 쉬운 코드는 '순수 함수(Pure Function)' 와 가깝다. 같은 입력에 항상 같은 결과를 내놓고, 외부 세상과 단절된 형태. 좋은 단위 테스트를 작성하려면 우리의 코드를 이런 순수 함수 형태로 만들려는 노력이 필요하다.
테스트하기 어려운 영역을 분리해서 외부에서 제어할 수 있도록 만들자. 이것이 테스트 가능한 설계를 위한 핵심이다.
'Software Engineering > Test' 카테고리의 다른 글
[Test] Business Layer Test(with Spring Boot) (0) | 2025.07.05 |
---|---|
[Test] Persistence Layer Test(with Spring Boot, JPA) (0) | 2025.07.05 |
[Test] 테스트는 '문서'다. (0) | 2025.07.05 |
[Test] TDD(Test Driven Development) (0) | 2025.07.05 |
[Test] 테스트를 해야 하는 이유 (0) | 2025.07.05 |