
이 글은 인프런 강의 'Practical Testing : 실용적인 테스트 가이드'를 보고 내용을 정리한 글이다. (섹션 1 ~ 5)
Practical Testing: 실용적인 테스트 가이드 강의 | 박우빈 - 인프런
박우빈 | , 실무에 맞는 올바른 테스트 코드 그 첫걸음이 되어드릴게요! [사진] [임베딩 영상] 테스트 코드가 정말 그렇게 중요한가요? 🤔 그럼요! 테스트 코드 없이는 내가 만든 애플리케이션
www.inflearn.com
1. Intro
테스트를 작성하는 역량
- 주니어 개발자에게 가장 기대하는 요소 중 하나
- 채용 시 구현 과제 등에서 테스트 작성 여부, 테스트 코드 구현 방식을 확인
- 소프트웨어의 품질을 보증하는 방법으로, 그 중요성을 알고 있는지를 확인
강의를 통해 학습하는 내용들
- 테스트 코드가 필요한 이유
- 좋은 테스트 코드란 무엇일까?
- 실제 실무에서 진행하는 방식 그대로 테스트를 작성해가면서 API를 설계하고 개발하는 방법
- 정답은 없지만, 오답은 있다! 구체적인 이유에 근거한 상세한 테스트 작성 팁
학습 목표
- 테스트 코드의 필요성을 이해하고 숙지하기
- 좋은 테스트 코드가 무엇이고 깔끔하고 명확한 테스트 코드를 작성하는 방법을 학습하고 정리하기
- Spring 및 JPA 기반의 API를 설계하고 개발하는 과정에서 실무 수준의 테스트 코드를 작성하고 이를 정리하기

2. 테스트는 왜 필요할까?
테스트 코드를 작성하지 않는다면
📌 변화가 생기는 매순간마다 발생할 수 있는 모든 Case를 고려해야 한다.
📌 변화가 생기는 매순간마다 모든 팀원이 동일한 고민을 해야한다.
📌 빠르게 변화하는 소프트웨어의 안정성을 보장할 수 없다.
테스트 코드가 병목이 된다면
📌 프로덕션 코드의 안정성을 제공하기 힘들어진다.
📌 테스트 코드 자체가 유지보수하기 어려운, 새로운 짐이 된다.
📌 잘못된 검증이 이루어질 가능성이 생긴다.

올바른 테스트 코드는
✅ 자동화 테스트로 비교적 빠른 시간 안에 버그를 발견할 수 있고, 수동 테스트에 드는 비용을 크게 절약할 수 있다.
✅ 소프트웨어의 빠른 변화를 지원한다.
✅ 팀원들의 집단 지성을 팀 차원의 이익으로 승격시킨다.
✅ 가까이에서 보면 느리지만, 멀리서 보면 가장 빠르다.
3. 단위 테스트
요구사항
- 주문 목록에 음료 추가 / 삭제 기능
- 주문 목록 전체 지우기
- 주문 목록 총 금액 계산하기
- 주문 생성하기
개발 환경
프로젝트 주제인 '초간단 카페 키오스크 시스템'을 개발하면서 테스트 코드를 작성하는 방법을 배울 수 있다.
Junit5, AssertJ
- 단위테스트란 작은 코드 단위를 독립적으로 검증하는 테스트이다. 여기서 작은 코드는 클래스 혹은 메서드를 의미한다.
- 단위테스트는 검증 속도가 빠르고 안정적인 특징을 가진다.
- Junit5란 단위 테스트를 위한 테스크 프레임워크이다.
- AssertJ란 테스트 코드 작성을 원활하게 돕는 테스트 라이브러리이다. 풍부한 API, 메서드 체이닝을 지원한다.
테스트 케이스 세분화하기
요구사항 : 한 종류의 음료 여러 잔을 한 번에 담는 기능
이러한 요구 사항이 들어왔을 때, 자신에게 혹은 요구 사항을 들고 온 기획자, 타직군에게 다시 질문을 해 볼 수 있어야 한다.
질문하기 : 암묵적이거나 아직 드러나지 않은 요구 사항이 있는지 항상 염두하고 고민을 해봐야 한다.
해피 케이스와 예외 케이스, 이 2가지 케이스를 가지고 경계값 테스트를 도출 할 수 있어야 한다.
- 여기서 경계값 테스트는 범위(이상, 이하, 미만, 초과), 구간, 날짜 등을 말한다.
- 예를 들어, 어떤 정수 값이 있고 이 정수가 3이상일 때, A라는 조건을 만족한다고 가정한다면
- 3 이상에 대한 해피 케이스와 3 미만에 대한 예외 케이스를 작성해야 한다.
해당 요구 사항(한 종류의 음료 여러 잔을 한 번에 담는 기능)에 대해 해피 케이스와 예외 케이스를 작성해보면 아래와 같다.
@Test
void addSeveralBeverages() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano, 2);
assertThat(cafeKiosk.getBeverages().get(0)).isEqualTo(americano);
assertThat(cafeKiosk.getBeverages().get(1)).isEqualTo(americano);
}
@Test
void addZeroBeverages() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
assertThatThrownBy(() -> cafeKiosk.add(americano, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("음료는 1잔 이상 주문하실 수 있습니다.");
}
addSeveralBeverages 메서드는 하나의 종류인 아메리카노에 2개를 담는 테스트로 해피 케이스이고,
addZeroBeverages 메서드는 하나의 종류인 아메리카노에 0개를 담았을 때, 예외가 발생하는 예외 케이스인 것을 확인할 수 있다.
테스트 하기 어려운 영역
요구사항 : 가게 운영 시간(10:00 ~ 22:00) 외에는 주문을 생성할 수 없다.
영업 시간 내에 주문이 생성되려면 시간과 관련된 부분도 고려하기 떄문에 테스트하기 어려울 수 있다.
주문을 생성하는 로직에 시간과 관련된 부분을 추가하게 되면, 기존 프로덕션 코드가 전부 수정되어야 하기 때문에 좋지 않다.
이를 위해 주문을 생성할 때 외부에서 시간 데이터를 받아올 수 있도록 변경한다면
테스트 시에는 원하는 시간을 통해 검증할 수 있고, 프로덕션 코드에서도 현재 시간을 인자로 주어 동작할 수 있다.
@Getter
public class CafeKiosk {
public static final LocalTime SHOP_OPEN_TIME = LocalTime.of(10, 0);
public static final LocalTime SHOP_CLOSE_TIME = LocalTime.of(22, 0);
// 변경 전
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);
}
// 변경 후: 외부에서 시간 데이터를 받아올 수 있도록 변경
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);
}
}
그리고 createOrder 메서드를 기반으로 테스트 코드를 짜면 아래와 같다.
class CafeKioskTest {
@Test
void createOrder() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
Order order = cafeKiosk.createOrder();
assertThat(order.getBeverages()).hasSize(1);
assertThat(order.getBeverages().get(0).getName()).isEqualTo("아메리카노");
}
@Test
void createOrderWithCurrentTime() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
Order order = cafeKiosk.createOrder(LocalDateTime.of(2023, 11, 30, 10, 0));
assertThat(order.getBeverages()).hasSize(1);
assertThat(order.getBeverages().get(0).getName()).isEqualTo("아메리카노");
}
@Test
void createOrderOutsideOpenTime() {
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
cafeKiosk.add(americano);
assertThatThrownBy(() -> cafeKiosk.createOrder(LocalDateTime.of(2023, 11, 30, 9, 59)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("주문 시간이 아닙니다. 관리자에게 문의하세요.");
}
}
이처럼 테스트하기 어려운 영역을 외부로 분리할수록 테스트 가능한 코드는 많아진다.
무조건 최상위 계층까지 분리해야 하는 것은 아니다. 적당한 선을 가지고 분리를 멈추는 게 좋다.
테스트하기 어려운 영역은 크게 2가지이다.
- IN : 관측할 때마다 다른 값에 의존하는 코드 - 현재 날짜/시간, 랜덤 값, 전역변수/함수, 사용자 입력
- OUT : 외부 세계에 영향을 주는 코드 - 표준 출력, 메시지 발송, 데이터베이스에 기록하기
반대로 테스트 하기 쉬운 영역은 외부 세계와 단절된, 함수형 프로그래밍에서는 순수 함수(pure functions)라고 한다.
- 같은 입력에는 항상 같은 결과
- 외부 세상과 단절된 형태
- 테스트하기 쉬운 코드
4. TDD : Test Driven Development
TDD (Test Driven Development) 란 프로덕션 코드보다 테스트 코드를 먼저 작성하여 테스트가 구현 과정을 주도하는 방법론이다.

TDD는 RED -> GREEN -> REFACTOR 3가지 사이클을 반복하는 일정한 리듬 속에서 진행된다.

- RED : 프로덕션 코드가 없는 상황에서 실패하는 테스트 코드를 먼저 작성한다.
- GREEN : 실패하는 테스트 코드를 통과하기 위해 프로덕션 코드에서 최소한의 코드를 작성한다.
- REFACTOR : 프로덕션 코드를 좋은 코드로 개선한다.
TDD의 가장 큰 핵심 가치는 피드백이다. 내가 작성한 코드, 프로덕션 코드에 대해 자주, 빠르게 피드백을 받을 수 있다는 것이다.
- 선 기능 구현, 후 테스트를 작성하게 되면 아래와 같은 문제가 발생할 수 있다.
- 테스트 자체의 누락 가능성
- 특정 테스트 케이스(해피 케이스)만 검증할 가능성
- 잘못된 구현을 다소 늦게 발견할 가능성
- 선 테스트 작성, 후 기능을 구현하게 되면 아래와 같은 장점을 마주하게 된다.
- 복잡도가 낮은(유연하며 유지보수가 쉬운), 테스트 가능한 코드로 구현할 수 있다.
- 쉽게 발견하기 어려운 엣지(Edge) 케이스를 놓치지 않게 해준다.
- 구현에 대한 빠른 피드백을 받을 수 있다.
- 과감한 리팩토링이 가능해진다.
5. 테스트는 [ ] 다.
테스트는 문서다.
테스트를 문서라고 한 이유
- 프로덕션 기능을 설명하는 테스트 코드는 문서가 될 수 있다.
- 다양한 테스트 케이스를 통해 프로덕션 코드를 이해하는 시각과 관점을 보완할 수 있다.
- 어느 한 사람이 과거에 경험했던 고민의 결과물을 팀 차원으로 승격시켜서, 모두의 자산을 공유할 수 있다.
이를 통해 테스트를 작성한 이유는 실무에서는 항상 팀으로 일하기 때문에 나 또는 다른 누군가가 작성한 문서가 팀 전체에 큰 도움을 줄 수 있기 때문이다.
DisplayName을 섬세하게
테스트 코드를 작성하다 보면 "테스트의 이름을 어떻게 짓는게 좋은걸까?"라는 고민을 할 수 있다.
이를 위해 Junit5에서 추가된 @DisplayName이라는 어노테이션을 활용하면 테스트에 대한 설명을 한글로 작성해서 어떤 테스트를 의미하는지 쉽게 알 수 있다.
// @DisplayName("음료 1개 추가 테스트") -> 이 방법 지양하기
@DisplayName("음료 1개를 추가하면 주문 목록에 담긴다")
@Test
void add() {
CafeKiosk cafeKiosk = new CafeKiosk();
cafeKiosk.add(new Americano());
assertThat(cafeKiosk.getBeverages().size()).isEqualTo(1); // 크기가 1과 같은지 확인
assertThat(cafeKiosk.getBeverages()).hasSize(1); // 크기가 1과 같은지 확인
assertThat(cafeKiosk.getBeverages().get(0).getName()).isEqualTo("아메리카노");
}

어떻게 DisplayName을 섬세하게 작성해야할까?
- "~테스트"라고 작성하는 것을 지양하고 문장으로 작성한다. -> A이면 B이다. / A이면 B가 아니고 C다.
- 음료를 1개 추가 테스트 (X)
- 음료를 1개 추가할 수 있다. (O)
- 조금 더 나아가서 테스트 행위에 대한 결과까지 기술한다.
- 음료를 1개 추가할 수 있다. (X)
- 음료를 1개 추가하면 주문 목록에 담긴다. (O)
- 도메인 용어를 사용 (메서드 자체의 관점보다는 도메인 정책 관점으로)해서 한층 추상화된 내용을 담는다.
"~실패한다"와 같은 테스트의 현상을 중점으로 기술하지 않는다.- 특정 시간 이전에는 주문을 생성하면 실패한다. (X)
- 영업 시작 이전에는 주문을 생성할 수 없다. (O)
BDD 스타일로 작성하기
BDD (Behavior Driven Development)
- TDD에서 파생된 개발 방법으로 함수 단위의 테스트에 집중하기 보다,
시나리오에 기반한 테스트케이스(TC) 자체에 집중하여 테스트한다. - 개발자가 아닌 사람이 봐도 이해할 수 있을 정도의 추상화 수준(레벨)을 권장한다.
Given / When / Then
- Given : 시나리오 진행에 필요한 모든 준비 과정 (객체, 값, 상태)
- When : 시나리오 행동 진행
- Then : 시나리오 진행에 대한 결과 명시, 검증(AssertJ)
어떤 환경에서 (Given) 어떤 행동을 진행했을 때 (When), 어떤 상태 변화가 일어난다(Then)와 같이 3단계를 기반으로 작성하면
DisplayName 에 문장을 명확하게 작성할 수 있다.
@DisplayName("주문 목록에 담긴 상품들의 총 금액을 계산할 수 있다.")
@Test
void calculateTotalPrice() {
// given : 시나리오 진행헤 필요한 모든 준비 과정(객체, 값, 상태 등)
CafeKiosk cafeKiosk = new CafeKiosk();
Americano americano = new Americano();
Latte latte = new Latte();
cafeKiosk.add(americano);
cafeKiosk.add(latte);
// when : 시나리오 행동 진행
int totalPrice = cafeKiosk.calculateTotalPrice();
// then : 시나리오 진행에 대한 결과 명시, 검증
assertThat(totalPrice).isEqualTo(8500);
}
[Tip] IntelliJ에서 제공하는 Live Template 설정하는 방법
IntelliJ IDEA - Preferences - Live Templates - Java - test에서 아래
Template text 부분에 아래와 같이 입력하고 Apply 후 Ok 버튼을 클릭한다.

그러면 코드 입력 시 test만 입력하면 내가 설정한 템플릿이 자동으로 입력된다.
Reference
Practical Testing: 테스트 코드 작성 방법
devfancy.github.io
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!