Study/테스트 코드

Practical Testing : 실용적인 테스트 가이드 (테스트 코드 작성 방법)

킴준현 2025. 3. 13. 16:02

이 글은 인프런 강의 'Practical Testing : 실용적인 테스트 가이드'를 보고 내용을 정리한 글이다. (섹션 1 ~ 5)

 

Practical Testing: 실용적인 테스트 가이드 강의 | 박우빈 - 인프런

박우빈 | , 실무에 맞는 올바른 테스트 코드 그 첫걸음이 되어드릴게요!  [사진] [임베딩 영상] 테스트 코드가 정말 그렇게 중요한가요? 🤔 그럼요! 테스트 코드 없이는 내가 만든 애플리케이션

www.inflearn.com

1. Intro

테스트를 작성하는 역량

  • 주니어 개발자에게 가장 기대하는 요소 중 하나
  • 채용 시 구현 과제 등에서 테스트 작성 여부, 테스트 코드 구현 방식을 확인
  • 소프트웨어의 품질을 보증하는 방법으로, 그 중요성을 알고 있는지를 확인

강의를 통해 학습하는 내용들

  • 테스트 코드가 필요한 이유
  • 좋은 테스트 코드란 무엇일까?
  • 실제 실무에서 진행하는 방식 그대로 테스트를 작성해가면서 API를 설계하고 개발하는 방법
  • 정답은 없지만, 오답은 있다! 구체적인 이유에 근거한 상세한 테스트 작성 팁

학습 목표

  1. 테스트 코드의 필요성을 이해하고 숙지하기
  2. 좋은 테스트 코드가 무엇이고 깔끔하고 명확한 테스트 코드를 작성하는 방법을 학습하고 정리하기
  3. 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


https://devfancy.github.io/Practical-Testing/#displayname%EC%9D%84-%EC%84%AC%EC%84%B8%ED%95%98%EA%B2%8C

 

Practical Testing: 테스트 코드 작성 방법

 

devfancy.github.io