개발

단위테스트 적응기 1편

1223v 2024. 7. 16. 01:09

고민의 시작 🌟🌟🌟


보통 대학생 프로젝트를 진행하면, 개발 - 기획 - 디자인 - 마케팅의 영역이 명확하게 분리되어 있지 않고, 기한에 맞춰 데드라인 개발을 하는 경우가 많을거다. 그래서, 규모가 작은 프로젝트는 테스트를 작성하지 않고 진행하는 경우가 많고 이로 인해 모든 서비스가 의존하고 절차지향적 코드가 되며, 이후 유지보수가 힘들어지는 코드가 된다.

또한, 모든 비즈니스 로직에서 발생하는 에러들이 서버를 돌리고 QA과정에서 발견되며, 이를 수정할 때 역시 단위별로 테스트할 수 있는 것이 아닌 유기적으로 연결되어있는 코드를 하나씩 따라가 보며 수정해야한다….

필자는 이러한 문제를 서비스가 완성되고 리펙토링하면서 이것이 과연 객체지향적이라 할 수 있는가? 라는 의문이 들게 되었고, 테스트 코드를 작성해보면서 코드도 단위테스트가 가능하도록 변경해보고자 단위테스트에 대한 내용을 정리해본다.

왜 이 고민이 필요해? 🌟🌟🌟🌟


통한 테스트는 여러 곳과 상호작용하기 때문에 모든 컴포넌트들이 구동된 상태에서 테스트를 하게 되므로, 캐시나 데이터베이스 등 실제 연결을 진행해야하고 컴포넌트들이 여러곳에 의존을 하고 있다면 테스트를 위한 시간이 커진다.

하지만, 단위 테스트는 테스트하고자 하는 부분만 독립적으로 테스트를 하기 때문에 해당 부분의 비즈니스 로직 보수 및 문제 인식을 빠르게 진행할 수 있다.

Result.

  • 원하는 부부만 테스트를 함으로 결과를 빠르게 볼 수 있다.
  • 미리 작성한 단위 테스트를 기반으로 프로덕션 코드의 리펙토링을 안정적으로 할 수 있다.
  • 단위 테스트가 실패하는 지점에서 문제점을 빠르게 찾을 수 있다.

단위테스트


특징

좋은 테스트 코드는 변경되는 요구사항에 맞춰 변경된 코드는 버그의 가능성을 항상 내포하고 있으므로, 이를 테스트 코드로 검증함으로써 해결할 수 있어야 한다.

실제 코드가 변경되면 테스트 코드도 변경이 필요할 수 있으며, 테스트 코드 역시 가독성 있도록하여 아래와 같은 일관된 규칙과 목적으로 테스트코드를 작성해야 한다.

FIRST 규칙

F ast: 테스트는 빠르게 동작하고 자주 가동 해야함

I ndependent : 각각의 테스트는 독립적이어야 하며, 서로에 대한 의존성은 없어야 함.

R epeatable : 어느 환경에서도 반복이 가능해야 함.

S elf-Validating : 테스트는 성공 또는 실패 값으로 결과를 내어 자체적으로 검증 되어야 함.

T imely : 테스트하려는 실제 코드를 구현하기 직전에 구현해야 함

한계

어플리케이션은 하나의 기능을 처리하기 위해 다른 객체들과 데이터를 주고 받는데, 단위테스트는 해당 기능에 대한 독립적인 테스트기 때문에 다른 객체와 데이터를 주고 받는 경우에 문제가 발생한다.

그래서 테스트를 하기위해 연관된 모듈에서 가짜 데이터(정해진 반환값)이 필요하다.

즉, 단위테스트는 테스트하고자하는 기능과 연관된 다른 모듈은 연결이 단절되어야 비로소 독립적인 단위테스트가 가능해진다.

단위 테스트 작성 방식


JUnit과 AssertJ를 사용하여 테스트 코드를 작성한다.

킹영한의 스프링 강의를 보면 한번쯤은 봤을 것이다.

Given / When / Then Pattern

  • Given : 어떠한 데이터가 주어질 때.
  • When : 어떠한 기능을 실행하면.
  • Then : 어떠한 결과를 기대
@Test
@DisplayName("Test")
void test() {
    // Given

    // When

    // Then

}

Mockito

동작을 직접 제어할 수 있는 가짜 객체를 지원하는 테스트 프레임웍.

Spring Application은 여러 객체들간의 의존성이 생기는데 이러한 의존성을 모키토를 이용하여 단절시킴으로 단위 테스트를 쉽게 작성하게 해줌.

단위 테스트 작성


간단한 단위 테스트 사용

public class Car {

    private int position;

    public Car() {
        this.position = 0;
    }

    //명령한 수 만큼 move() 메서드를 실행시키는 기능
    public void moveAsOrdered(final int orderCount) {
        this.position += orderCount;
    }

    public int getPosition() {
        return position;
    }
}
class CarApplication {

    public static void main(String[] args) {
        Scanner read = new Scanner(System.in);

        Car car = new Car();
        System.out.println("자동차를 몇번 이동 시키겠습니까?");
        int orderedCount = read.nextInt();

        car.moveAsOrdered(orderedCount);
        System.out.println("자동차는 " + car.getPosition() + " 번 움직였습니다");
    }
}
@Test
@DisplayName("자동차는 명령을 받은 만큼 이동거리가 증가")
void car_moves_as_ordered2() {

    //given
    int orderedCount = 10;
    Car car = new Car();

    //when
    car.moveAsOrdered(orderedCount);
    int distanceResult = car.getPosition();

    //then
    assertThat(distanceResult).isEqualTo(orderedCount);
@ParmeterizedTest
@ValueSource(ints = {-1, 0, 11, 12})
@DisplayName("자동차를 이동 시킬 거리가 1이상 10이하의 수가 아니면 예외 발생")
void throws_exception_when_order_count_invalid(int givenOrderCount) {

        //given
        String expectedErrorMessage = "이동 시킬 거리는 1 이상 10 이하의 수만 가능"
        Car car = new Car();

        //when & then
        assertThatThrowBy(()-> car.moveAsOrdered(givenOrderCount))
                    .isInstance(IllegalArgumentException.class)
                    .hasMessageContaining(expectedErrorMessage);
}

안좋은 단위 테스트는?


여러 개의 준비, 실행, 검증

여러개의 준비, 실행, 검증이 있는 경우는 단위 테스트라 보기가 힘듬 → 통합테스트의 일부라 볼 수 있음.

하나의 단위 테스트는 하나씩의 준비, 실행, 검증 사이클을 가지도록 설계

실행 구절이 두개

@Test
@DisplayName("구매를 성공하면, 재고가 감소한다")
void purchase_succeeds_when_enough_inventory(){
    // given
    Store = store = new Store();
    store.addInventory(Product.SHAMPOO, 10);
    Customer customer = new Customer();

    // when
    boolean success = customer.purchase(store, Product.SHAMPOO, 5);
    store.removeInventory(success, Product.SHAMPOO, 5);

    // then 
    assertEquals(5, store.getInventory(Product.SHAMPOO));
}

비즈니스 관점에서 행동 (물품구매)결과 (재고감소)결과 (고객이 물품 획득) 두개가 생김.

세부 사항에 너무 의존적인 코드

@DisplayName("덱에 카드가 없으면, 더이상 카드를 뽑을 수 없다.")
@Test
void should_ThrowIllegalArgumentException_When_NoMoreCard() {
    Deck shuffledDeck = Deck.createShuffledDeck();

    for (int i = 0; i<52; i++) { 
        shuffledDeck.draw();
    }

    assertThatThrowBy(shuffledDeck::draw)
                    .isInstanceOf(IllegalArgumentException.class);
    }

위 코드를 보면 52장이라는 것을 알고 있으며, draw 함수가 한장씩 뽑는 것도 알고 있다.

이러면, 만약 draw 가 원하는대로 뽑도록 변경이 되거나, 요구사항이 변경되는 경우, 테스트 코드 역시 수정해야하는 문제점이 발생한다.

목표 : 덱 생성시 카드가 없도록

  • 해결법 : Deck deck = Deck.createEmptyDeck();
  • 이에 발생하는 문제 : 프로덕트 코드가 덱 생성을 제어하기 힘듬

실질적 해결법 : 생성을 제어 가능하도록 → 전략 패턴 적용

public interface DeckCreateStrategy {
        Deque<Card> createDeck();
}

public class ShuffledDeckCreateStrategy implements DeckCreateStrategy {

    @Override
    public Deque<Card> createDeck() {
            return 셔플된 52개의 카드 덱;
    }
}
public class Deck {

        public Deck(DeckCreateStrategy deckCreateStrategy) {
                this.cards = deckCreateStrategy.createDeck();
        }
}

변경된 좋은 단위 테스트 코드

@DisplayName("덱에 카드가 없으면, 더이상 카드를 뽑을 수 없다.")
@Test
void should_ThrowIllegalArgumentException_When_NoMoreCard() {
    Deck shuffledDeck = new Deck(new DeckCreateStrategy() {
                @Override
                public Deque<Card> createDeck() {
                        return new ArrayDeque<Card>();
                }
        });

    assertThatThrowBy(shuffledDeck::draw)
                    .isInstanceOf(IllegalArgumentException.class);
    }

덱 생성전략을 인자로 받으므로써, 테스트 코드가 dip, ocp 원칙을 지키게 되었다.

이는 어떠한 생성전략이 들어와도 테스트가 가능해졌다.

고찰 🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟🌟


우선 테스트 코드에 대해 공부하면서 중요한 키워드는 의존 , 명확, 유연성 이다.

너무 많은 세부적인 내용을 알고있는 테스트 코드는 결국 외부에서 테스트 코드를 의존하게 되버린다.

즉, 독립성이 떨어진다는 것이다.

개선 전 코드는 52번의 draw 호출이 성공적으로 이루어져야 마지막 draw 호출에서 예외가 발생한다. 만약 중간에 오류가 발생하면 원하는 테스트를 수행하지 못할 수 있다.

하지만, 개선된 코드는 처음부터 빈텍을 생성하므로 이러한 문제가 발생하지 않으며, 테스트가 더 독립적으로 동작할 수 있게 된 것을 알 수 있다.

다음은 명확한 의도를 가져야 한다는 것이다.

우리가 확인하고 싶은 것은 빈 덱에서 카드를 뽑으려고 할 때 예외가 발생한다 라는 점이다.

하지만, 개선 전 코드는 52번의 draw 가 실행된 후에 덱이 비어야 한다는 간접적 방법을 사용하기 때문에 의도 파악이 어려운 것을 확인할 수 있다.

마지막으로 테스트는 확장에 용이하도록 유연성을 가져야한다는 점이었다.

개선된 코드에서는 DeckCreateStrategy 를 사용해서 덱을 생성하는 전략을 유연하게 변경할 수 있게 하였다. 이는 다양한 덱 상태를 쉽게 테스트할 수 있고 요구사항에 맞게 테스트 코드가 작동되기 때문에 비즈니스 코드가 변경되었다고 해서 테스트 코드까지 수정하는 상황이 발생하지 않게 된다.

예를 들어, 특정 카드가 없는 덱, 특정 카드만 있는 덱 등 다양한 상태의 덱을 쉽게 생성하여 테스트할 수 있다.