올바른 테스트 작성을 위한 규칙

2024년 4월 4일

테스트 코드를 작성하는 것이란, 본인이 앞서 작성한 코드들이 정상적으로 작동하는지에 대해 안정성을 보장 받는 행위이다.

그렇다면 올바른 테스트 작성을 위한 규칙은 뭐가 있을까 ?

1. 인터페이스를 기준으로 테스트를 작성하자.

여기서 인터페이스란 서로 다른 클래스 또는 모듈이 상호작용하는 시스템을 의미한다.

즉, 모든 메서드에 대해서 테스트 코드를 작성하기보다 외부에 노출되는 퍼블릭 메서드를 기준으로 작성해야한다.

캡슐화 된 내부에 대한 테스트 코드를 작성하는 것은, 모듈 내부에서 강한 의존성으로 인해 내부 코드가 조금만 수정이 되어도 깨지기 쉬운 테스트이다.

- 잘못된 테스트 코드

it('isShowModal 상태를 true로 변경 한다면, ModalComponent의 display 스타일이 block이며, "안녕하세요!" 텍스트가 노출 된다.', () => {
  // 구현에 종속적인 코드와 복잡한 상태 변경 코드들이 발생할 수 있습니다.
  SpecificComponent.setState({ isShowModal: true });
});

위의 코드를 예시로 든다면,

  1. 변경되는 상태가 많은 경우 테스트 코드 상에서 일일이 직접 변경해야하며, 어떤 상황에서 변경되는 것인지 드러나지 않는다.

  2. 또한 내부 상태나 변수값을 기준으로 검증하다보니, 어떤 것을 검증하는지 테스트 코드만 보고 한 눈에 파악하기 어렵다.

  3. 내부 구현을 검증하려다 보니, 구현에 종속적인 테스트 코드가 양산 된다. 상태나 변수명이 하나라도 변화한다면, 테스트 코드를 모두 바꿔야한다. 즉, 캡슐화를 위반한다.

- 올바른 테스트 코드

it("버튼을 누르면 모달을 띄운다.", () => {
  // 유저의 동작과 비슷하도록 클릭 이벤트를 발생

  user.click(screen.getByRole("button"));
});

위의 코드 같은 경우, 내부 구현과의 종속성이 없으므로 캡슐화에 위반되지 않는다.

또한 어떤 행위를 하는지 명확하며, 테스트를 설명하기 위한 불필요한 주석이나 설명이 없다.

2. 커버리지 보다는 의미있는 테스트인지 고민하자.

여기서 커버리지란 테스트 코드가 프로덕션 코드의 몇 %를 검증하고 있는지 나타낸 지표이다.

그렇다면 이 테스트 코드의 커버리지가 100%가 된다면 무조건 옳은 것일까 ?

당연히 아니다.

테스트 작성, 실행, 유지 보수 측면에서 너무 많은 비용이 발생한다.

100% 커버리지로 테스트를 작성했어도, 잘못된 검증때문에 문제가 발생할 수 있다.

즉, 100% 커버리지를 달성하는 것이, 무조건적으로 안정된 테스트 코드를 작성했다고 보장할 수 없다.

function isLargetThan5(value) {
  if (typeof value !== "number") {
    return false;
  }

  return value > 5;
}

it("isLargerThan5 test", () => {
  const result = isLargerThan5(100);
  const result2 = isLargerThan5("hello");
});

위의 코드를 예시로 들어보자.

해당 테스트 코드에서는 isLargerThan5 함수를 동작시키고 있지만, 해당 함수를 통해 검증의 단계를 거치고 있지 않다.

하지만 커버리지 부문에서는, 해당 함수를 실행 시키고 있기 때문에 검증이 된것으로 나올 수 있다.

즉, 커버리지의 결과를 신뢰할 수 없다.

또 다른 예시를 들어보자.

import React from "react";

const List = ({ items = [] }) => {
  return (
    <ul>
      {items.map((data) => {
        return <li key={data}>{data}</li>;
      })}
    </ul>
  );
};

export default List;

데이터를 props로 받아서 map 함수로 리스트 아이템을 뿌려주는 부분, 즉 단순한 UI 렌더링을 담당하는 부분에 대해서 테스트 코드를 작성하는 것이 의미가 있을까 ?

이 부분에 대해서 테스트 코드를 작성한다면 커버리지는 높아지겠지만, 실질적으로 테스트 코드를 통해 얻을수 있는 이점은 없다.

export const isNumber = (value) => typeof value === "number";
export const isString = (value) => typeof value === "string";

위의 타입을 검증해주는 유틸 함수들에 대해서, 테스트 작성이 필요할까 ?

간단한 유틸함수들 같은 경우는, 억지로 문제 상황을 만들지 않는 이상 에러가 발생할 확률은 적다.

위에서 말한 3가지의 테스트 같은 경우, 진짜 테스트 코드 작성이 아니라 커버리지만을 위한 검증이 될 수 있다.

100% 커버리지를 위한 테스트보다는, 이 테스트가 의미있는 테스트인가? 어떤 범위까지 검증해야지 효율적인 테스트가 될까? 에 대해서 고민해보아야한다.

3. 테스트 코드도 코드다. 가독성을 높이자.

테스트 코드 또한 어플리케이션 내부 코드에 따라서 끊임없이 변화하고 유지보수가 되어야할 대상이다.

그렇기 때문에, 테스트 코드 자체에 대한 가독성도 신경써야한다.

  1. 테스트 하고자 하는 내용을 명확하게 적자
it("리스트에서 항목이 제대로 삭제된다.", () => {
  //...
});

it("항목들을 체크한 후 삭제 버튼을 누르면, 리스트에서 체크 된 항목들이 삭제된다.", () => {
  // ...
});

위의 예시를 보면, 테스트에 대한 설명을 어떻게 적었느냐에 따라서 가독성의 편차가 큰 것을 확인할 수 있다.

  1. 하나의 테스트에서는 가급적 하나의 동작만 검증하자.

프로그래밍에서 SRP원칙이 존재하듯이, 테스트 코드 또한 하나의 테스트에선 하나의 모듈만 테스트 하는 것이 좋다.

it("장바구니에 담긴 상품들이 정상적으로 노출되고, 수량을 변경하면 가격이 재계산 된다. 그리고 삭제 버튼을 누르면 상품이 삭제된다.", () => {
  //...
});

위 예시처럼 하나의 테스트 코드에 상품 노출, 수량 변경, 삭제 같이 여러개의 동작이 있다면, 내부 테스트 코드의 유지보수성이 떨어질 수 있다.

만약 프로덕션 코드에서 가격 계산에 대한 로직만 수정이 되더라도, 해당 테스트 자체가 깨져버리게 된다.

it("장바구니에 담긴 상품들을 정상적으로 렌더링 한다.", () => {
  //...
});

it("장바구니에 담긴 상품의 수량을 수정하면 가격이 재계산 된다.", () => {
  //...
});

it("장바구니에 담긴 항목의 삭제 버튼을 누르면 리스트에서 삭제 된다.", () => {
  //...
});

위의 방식으로 테스트코드를 나누게 되면 유지보수성이 높아지고, 가독성 또한 높일 수 있다.