쌩로그

Practical Testing 실용적인 테스트 가이드 - Section 02. 단위 테스트 본문

Spring/Test

Practical Testing 실용적인 테스트 가이드 - Section 02. 단위 테스트

.쌩수. 2023. 12. 17. 16:46
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 샘플 프로젝트 소개 & 개발 환경 안내
      2-2. 프로젝트 세팅
      2-3. 수동테스트 VS 자동화된 테스트
      2-4. JUnit5로 테스트하기
      2-5. 테스트 케이스 세분화하기
      2-6. 테스트하기 어려운 영역을 분리하기
  3. 요약

1. 포스팅 개요

인프런에서 박우빈님의 Practical Testing: 실용적인 테스트 가이드 강의 섹션 2 단위 테스트를 학습하며 정리한 포스팅이다.

| 참고 이전 포스팅

2-1. 샘플 프로젝트 소개 & 개발 환경 안내

샘플 프로젝트

주문에 초점을 맞춘 초간단 카페 키오스크 시스템을 개발한다.

개발 환경

인텔리제이와 Vim(plugin)을 사용한다.

2-2. 프로젝트 세팅

  • Lombok
  • Spring Web
  • Spring Data JPA
  • H2 Database

위 라이브러리 4개를 선택하여 생성해준다.
추후 필요한 라이브러리가 있을 때 그때 그때 받는다.

2-3. 수동테스트 VS 자동화된 테스트

요구사항은 다음과 같다.

  • 주문 목록에 음료 추가/삭제 가능
  • 주문 목록 전체 지우기
  • 주문 목록 총 금액 계산하기
  • 주문 생성하기

이를 일단, 먼저는 콘솔 기반으로 테스트를 한다.

예시 코드이다.

public interface Beverage {

    String getName();   // 이름

    int getPrice();     // 가격
}
public class Americano implements Beverage {

    @Override
    public String getName() {
        return "아메리카노";
    }

    @Override
    public int getPrice() {
        return 4000;
    }
}
public class Latte implements Beverage {

    @Override
    public String getName() {
        return "라떼";
    }

    @Override
    public int getPrice() {
        return 4500;
    }
}
@Getter
@RequiredArgsConstructor
public class Order {


    private final LocalDateTime orderDateTime;
    private final List<Beverage> beverages;
}
@Getter
public class CafeKiosk {

    private List<Beverage> beverages = new ArrayList<>();

    public void add(Beverage beverage) {
        beverages.add(beverage);
    }

    public void remove(Beverage beverage) {
        beverages.remove(beverage);
    }

    public void clear() {
        beverages.clear();
    }

    public int calculateTotalPrice() {
        int totalPirce = 0;
        for (Beverage beverage : beverages) {
            totalPirce += beverage.getPrice();
        }
        return totalPirce;
    }

    public Order createOrder() {
        return new Order(LocalDateTime.now(), beverages);
    }
}

아래는 위에 작성된 코드들을 가지고 main 함수로 테스트하는 코드다.


    public static void main(String[] args) {
        CafeKiosk cafeKiosk = new CafeKiosk();
        cafeKiosk.add(new Americano());
        System.out.println(">>> 아메리카노 추가");

        cafeKiosk.add(new Latte());
        System.out.println(">>> 라떼 추가");

        int totalPrice = cafeKiosk.calculateTotalPrice();
        System.out.println("총 주문가격 : " + totalPrice);


    }
}

결과는 다음과 같다.

현재 테스트 코드를 짜지 않고, 메인의 함수 실행으로 눈으로 직접 확인하고 있다.
이번에는 테스트 코드를 짜보자.

class CafeKioskTest {

    @Test
    void add() {
        CafeKiosk cafeKiosk = new CafeKiosk();
        cafeKiosk.add(new Americano());

        System.out.println(">>> 담긴 음료 수 : " + cafeKiosk.getBeverages().size());
        System.out.println(">>> 담긴 음료 : " + cafeKiosk.getBeverages().get(0).getName());

    }
}

결과는 다음과 같다.

그런데 이 테스트는 과연 올바른 테스트인가?
테스트를 기계의 힘을 빌려서 자동화하자고 했는데, 자동화를 한 테스트인가?
또한 상황을 만들어서 테스트를 했는데, 콘솔을 통해서 결과를 확인했다.
최종적인 확인은 기계가 아니라 사람이 했다.

여기서 두 가지 문제가 있는데,
하나는 최종 단계에서는 결국 사람이 개입해야하고,
또 다른 하나는 다른 사람이 이 테스트 코드를 봤을 때 뭘 검증해야 되는지 어떤 게 맞는 상황인지, 아닌지 구별할 수가 없다. (심지어 예시 코드는 무조건 성공하는 테스트코드다.)

따라서 바로 다음 수동테스트와 자동화된 테스트를 인지하고, JUnit을 사용해서 자동으로 검증하는 법을 알아본다.

2-4. JUnit5로 테스트하기

단위 테스트

  • 작은 코드 단위독립적으로 검증하는 테스트이다.
    • 작은 코드 단위클래스나 메서드를 의미한다.
    • 독립적으로 검증하는 테스트외부적인 요소나 상황이 아닌 클래스나 메소드를 검증할 수 있는 것을 의미한다.
  • 검증 속도가 다른 테스트에 비해 빠르고, 안정적이다.

JUnit5 프레임워크와 AssertJ 라이브러리를 가지고 테스트 검증을 할 것이다.

JUnit5

  • 단위 테스트를 위한 테스트 프레임워크이다.

AssertJ

  • 테스트 코드 작성을 원활하게 돕는 테스트 라이브러리다.
  • JUnit5보다 풍부한 API, 메서드 체이닝을 지원한다.

참고로 스프링 부트를 시작하면 spring-boot-starter-test에 두 라이브러리가 포함되어있다.
Americano 테스트를 해보면 다음과 같다.

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;

class AmericanoTest {

    @Test
    void getName() {
        Americano americano = new Americano();

        // 이름이 "아메리카노"인지 검증
        assertEquals(americano.getName(), "아메리카노"); // JUnit5의 API
        assertThat(americano.getName()).isEqualTo("아메리카노"); // AssertJ의 API
    }

    @Test
    void getPrice() {
        Americano americano = new Americano();

        assertEquals(americano.getPrice(), 4000);       // JUnit5의 API
        assertThat(americano.getPrice()).isEqualTo(4000);   // AssertJ의 API
    }

}

결과는 다음과 같다.

JUnit과 AssertJ 를 보면 AssertJ의 테스트 방식이 조금 더 명시적인 것을 확인할 수 있다.
isEqualTo 라는 메서드를 통해서 결과가 같은 것인지에 대한 의미를 명시적으로 알려준다.

이전에 테스트 클래스에서 콘솔로 결과를 확인한 테스트를 수정해보자.

이전 코드는 다음과 같다.

    @Test
    void add_manual_test() {
        CafeKiosk cafeKiosk = new CafeKiosk();
        cafeKiosk.add(new Americano());

        System.out.println(">>> 담긴 음료 수 : " + cafeKiosk.getBeverages().size());
        System.out.println(">>> 담긴 음료 : " + cafeKiosk.getBeverages().get(0).getName());

    }

다음은 그 결과다.

다음은 수정한 테스트 코드이다.

    @Test
    void add() {
        CafeKiosk cafeKiosk = new CafeKiosk();
        cafeKiosk.add(new Americano());

        assertThat(cafeKiosk.getBeverages().size()).isEqualTo(1);
        assertThat(cafeKiosk.getBeverages().get(0).getName()).isEqualTo("아메리카노");
    }

다음은 결과이다.
담긴 음료의 수와 담긴 음료가 아메리카노가 맞는지 확인한다.

참고로

assertThat(cafeKiosk.getBeverages().size()).isEqualTo(1);

이 코드를 다음처럼도 할수 있다.

assertThat(cafeKiosk.getBeverages()).hasSize(1); // 위의 코드와 같은 의미

다음은 삭제 테스트이다.

    @Test
    void remove() {
        CafeKiosk cafeKiosk = new CafeKiosk();
        Americano americano = new Americano();

        cafeKiosk.add(americano);
        assertThat(cafeKiosk.getBeverages()).hasSize(1);

        cafeKiosk.remove(americano);
        assertThat(cafeKiosk.getBeverages()).isEmpty();
    }

결과는 다음과 같다.

다음은 목록 비우기 테스트이다.

    @Test
    void clear() {
        CafeKiosk cafeKiosk = new CafeKiosk();
        Americano americano = new Americano();
        Latte latte = new Latte();

        cafeKiosk.add(americano);
        cafeKiosk.add(latte);
        assertThat(cafeKiosk.getBeverages()).hasSize(2);

        cafeKiosk.clear();
        assertThat(cafeKiosk.getBeverages()).isEmpty();
    }

다음은 결과이다.

다음 그림은 전체 결과이다.

콘솔에 찍히는 것은 원래 있던 테스트 결과다.

이처럼 자동화된 테스트를 했다. 여기서 사람이 확인한 건 없었다.
단지 테스트를 통과하는지 안 하는지만 확인했다.

프로덕트의 단위 테스트가 많이 작성된다면 실제 구현 내용이 변경되더라도 지속적으로 테스트 코드를 수행함으로써 프로덕션 코드가 정상적으로 작동하는지 사람의 개입없이 체크할 수 있다.

2-5. 테스트 케이스 세분화하기

요구사항이 추가되었다고 하자. 추가된 요구 사항은 다음과 같다.

  • 한 종류의 음료 여러 잔을 한 번에 담는 기능

일일이 하나의 음료 1잔마다 list에 추가했다면, 수량을 n개 정해서 추가할 수 있게 한다는 것이다.

이를 통해서 테스트 케이스를 어떻게 세분화하고 나눌 수 있을까..??

요구사항이 들어왔을 때 확인해야 할 것이 있다.
요구사항에 대해서 기획자나 디자이너 혹은 타 직군 사람들에게 다시 질문해볼 수 있어야 한다.
또한 과연 실제 구현할 때의 요구사항과 정확히 맞아 떨어지는지를 확인하고, 암묵적으로 이야기를 안 한 것이 있는지, 혹은 도출이 안 되어서 드러나지 않은 요구사항이 있는지에 대한 염두에 두고, 고민을 해야한다.

그리고 요구사항에 대하여 두 가지 케이스를 확인해봐야 한다.
요구사항을 그대로 만족하는 해피 케이스예외 케이스이다.

지금까지 했던 테스트는 해피케이스에 대한 테스트만 했다.
만약 수를 입력할 때 -1이나 혹은 0을 입력했을 때는 예외에 대한 처리도 해야한다.

그리고 해피 케이스와 예외 케이스를 테스트 할 때는 경계값 테스트를 해야한다.

경계값 테스트범위(이상, 이하, 초과, 미만), 구간, 날짜 등 이러한 조건에 대한 테스트이다.
커피 수량은 1이상이되어야 하는데, 음수이거나, 0일 때에 대한 예외 케이스를 테스트 해야한다.

다음은 코드로 요구 사항을 구현해보자.

public void add(Beverage beverage, int count) {
        // 0 혹은 1이 들어왔을 때 예외를 던진다.
        if(count <= 0) {
            throw new IllegalArgumentException("음료는 1잔 이상 주문하실 수 있습니다.");
        }
        for(int i =0 ; i< count; i++) {
            beverages.add(beverage);
        }
    }

기존 public void add(Beverage beverage)메서드를 오버로딩했다.

테스트 코드는 다음과 같다.

    @Test
    void addServeralBeverages() {
        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잔 이상 주문하실 수 있습니다.");     // 어떤 메세지인지
    }

위의 코드는 해피 케이스이고,
아래 케이스는 예외 케이스이다.

결과는 다음과 같다.

2-6. 테스트하기 어려운 영역을 분리하기

추가 요구사항이 있다. 다음과 같다.

  • 가게 운영 시간(10:00 ~ 22:00)외에는 주문을 생성할 수 없다.
    • 이 외 시간에는 키오스크에서는 주문이 불가능하다. 관리자에게 문의하라는 메세지를 띄운다.

CafeKiosk 클래스에 createOrder() 메서드와 상수(가게 운영시간)를 다음과 같이 작성한다.


   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);
   }

다음과 같이 테스트 코드를 작성한다.

    @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("아메리카노");
    }

그런데 이 테스트는 늘 성공하는 것이 아니다.
현재 테스트 코드를 작성하고 있는 시점이 10시에서 22시 사이이기 때문에 성공하는 것이다.
현재 시간 오후 4시 10분 정도인데, 가게 오픈 시간을 17로 변경하면, 테스트가 실패한다.

public class CafeKiosk {
   public static final LocalTime SHOP_OPEN_TIME = LocalTime.of(17, 0);

   ...

}

이를 테스트할 수 있도록 createOrder()메서드를 오버로딩하자.
LocalDateTime을 메서드 내부에서 사용했는데, 외부에서 받도록 수정했다.

    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);
    }

다음은 테스트 코드이다.

@Test
    void createOrderWithCurrentTime() {
        CafeKiosk cafeKiosk = new CafeKiosk();
        Americano americano = new Americano();
        cafeKiosk.add(americano);

        // 운영시간~
        Order order = cafeKiosk.createOrder(LocalDateTime.of(2023, 12, 17, 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);

        // 웅영시간 1초전
        assertThatThrownBy(() -> cafeKiosk.createOrder(LocalDateTime.of(2023, 12, 17, 9, 59)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("주문 시간이 아닙니다. 관리자에게 문의하세요");

    }

결과는 다음과 같다.

이처럼 상황에 따라 테스트 케이스가 깨진다는 것을 인지하여 구현 코드를 변경했다.
시간에 대한 값을 메서드 내에서 정하는 것이 아니라, 외부에서 받도록 하였다.
그래서 프로덕션 코드에서는 현재 시간을 넣어주고, 테스트시엔 원하는 값을 넣도록 했다.

테스트하기 어려운 영역을 구분하고 분리하는 시야를 길러야 한다.

지금처럼 현재 시간에 대한 제약조건으로 테스트가 어려워지는 경우, 전체가 테스트 불가능한 상태가 된다.
방금 케이스처럼 테스트를 수행할 때마다 값이 달라져서 테스트가 깨졌다가 성공했다가 하는 경우이다.
그리고 다음 순서로 테스트가 어려워지는 영역을 외부로 분리했다.
LocalDateTime을 메서드가 아니라 외부에서 받도록 하였다.

"이렇게 해도되나?"라는 질문을 할 수도 있다. 하지만,
지금 테스트 케이스에서 중요한 것은 어떤 시각이 주어졌을 때, 이 시각에 주문이 받아지는지 안 받아지는지에 대한 조건을 판단하는 것이 중요하다.

테스트 하고자 하는 것은 어떤 시각과 같은 주체가 아니라, 시간과 같은 주체에 해당하는 범위이다.
그래서 테스트 코드상에서 원하는 값을 넣어줄 수 있도록 설계를 변경하는 것이 중요하다.

따라서 테스트하기 어려운 영역을 외부로 분리할 수록 테스트 가능한 코드는 많아진다.
좀 더 자세한 이야기는 추후에 나온다.

테스트하기 어려운 영역

테스트하기 어려운 영역은 다음과 같다.

  • 관측할 때마다 다른 값에 의존하는 코드
    • 현재 날짜/시간, 랜덤 값, 전역 변수/함수, 사용자 입력 등
  • 외부 세계에 영향을 주는 코드
    • 표준 출력, 메세지 발송, 데이터베이스에 기록하기 등

정리하자면 어떤 특정한 값을 주었을 때, 테스트 시점마다 테스트 성공/실패 여부가 달라지는 것을 인지했고, 그 특정한 값을 외부에서 줄 수 있도록 하여 테스트가 가능해지도록 하였다.
그리고 경계값을 테스트함으로써 조건과 범위가 잘 동작하는지 확인했다.

반대로 테스트하기 쉬운 영역은 다음과 같다.

  • 같은 입력에는 항상 같은 결과
  • 외부 세상과 단절된 형태
  • 테스트하기 쉬운 코드

테스트에 대한 시야를 계속 길러야한다..!!

3. 요약

단위 테스트에 대한 이야기를 하면서

  • 수동 테스트와 자동화 테스트에 대한 개념을 이해해보았다.
  • JUnit5 프레임워크와 AssertJ 라이브러리를 활용하여 테스트 코드를 작성했다.
  • 해키 케이스, 예외 케이스에 대해 생각해보며 경계값 테스트를 진행해보았다.
  • 테스트하기 쉬운 영역과 어려운 영역에 대해 구분하는 시야를 기르는 연습이 필요하다.

다음 섹션과 그 다음 섹션이 짧기에 두 섹션을 한번에 포스팅한다.

728x90
Comments