들어가며

닥터차 API 서버 레포지토리에는 기존에 작성된 11,373줄의 수많은 테스트 코드들이 있었습니다. 이 테스트 코드들은 단순히 테스트 커버리지 지표를 높이기 위해 코드의 한줄 한줄을 의미없이 재검증하는 테스트 코드들이었습니다. 이 테스트 코드들은 시스템의 안정성을 높이는데 전혀 기여하지 못했고, 단지 테스트의 대상이 되는 코드들을 수정하기 어렵게 만들 뿐이었습니다. 개발자들은 코드를 변경하면서 그에 대한 테스트 코드를 변경해야 할 이유를 찾지 못했습니다. 시간이 지나면서 테스트 코드들은 대상 코드들과 어긋나며 대부분의 테스트 케이스들이 실패하게 되었습니다.

테스트 코드 작성 시나리오

기존의 실패한 테스트 코드들을 전부 삭제하고 테스트가 필요한 부분에 테스트 코드를 도입해 보면서 테스트 코드를 어떤 상황에서 작성하면 도움이 되는지 정리해 보았습니다.

1. 문서로 설명하기 힘든 비즈니스 로직

자동차세 납부 내역을 바탕으로 환급 가능한 최대 자동차세액을 계산하는 비즈니스 로직은 다음과 같습니다.

  1. 차량번호 별로 자동차세 납부 내역을 묶는다.

  2. 각 차량과 그에 대한 자동차세 납부 내역에 대해 다음 로직을 따른다.

    Untitled Diagram.drawio.png

플로우 차트 형태로 보았을 때는 이해가 쉽지만, 글로 풀어쓰면 꽤나 길어져 한 번에 이해하기 어려울 수 있습니다. 또한 README 파일 등에 문서로 비즈니스 로직을 작성하더라도, 향후 로직을 변경할때 문서까지 같이 변경하는 것을 개발자가 잊을 수도 있습니다. 문서를 변경하는 것을 잊지 않았더라도 변경 과정에서 비즈니스 로직이 잘못 서술 될 수도 있습니다.

이 비즈니스 로직을 테스트하는 코드는 다음과 같습니다.

describe('AutomobileTaxRefundAmountCalculator', () => {
  it('보유 중인 차량을 제외합니다.', () => {
    const taxPayer = new TaxPayer({
      possessedAutomobiles: [PLATE_NUMBER],
      automobileTaxPaymentHistory: [
        new AutomobileTaxPayment({
          plateNumber: PLATE_NUMBER,
          claimedAt: WITHIN_FIVE_YEARS,
          amount: AMOUNT,
          singlePaid: true,
        }),
      ],
    });

    const refundableAutomobiles = taxPayer.refundableAutomobiles();

    expect(refundableAutomobiles).not.toIncludeAutomobileWithPlateNumber(
      PLATE_NUMBER,
    );
  });

  it('마지막 자동차세 납부 내역이 5년 이전인 차량을 제외합니다.', () => {
    const DEC_OF_SIX_YEARS_AGO = new YearMonthVO({
      year: DateVO.today().year.subtract(6),
      month: MonthEnum.DEC,
    });

    const taxPayer = new TaxPayer({
      possessedAutomobiles: [],
      automobileTaxPaymentHistory: [
        new AutomobileTaxPayment({
          plateNumber: PLATE_NUMBER,
          claimedAt: DEC_OF_SIX_YEARS_AGO,
          amount: AMOUNT,
          singlePaid: true,
        }),
      ],
    });

    const refundableAutomobiles = taxPayer.refundableAutomobiles();

    expect(refundableAutomobiles).not.toIncludeAutomobileWithPlateNumber(
      PLATE_NUMBER,
    );
  });

  it('마지막 자동차세 납부 내역이 일시납이 아닌 차량을 제외합니다.', () => {
    const taxPayer = new TaxPayer({
      possessedAutomobiles: [],
      automobileTaxPaymentHistory: [
        new AutomobileTaxPayment({
          plateNumber: PLATE_NUMBER,
          claimedAt: WITHIN_FIVE_YEARS,
          amount: AMOUNT,
          singlePaid: false,
        }),
      ],
    });

    const refundableAutomobiles = taxPayer.refundableAutomobiles();

    expect(refundableAutomobiles).not.toIncludeAutomobileWithPlateNumber(
      PLATE_NUMBER,
    );
  });

  it('현재 보유 중이지 않으며, 마지막 자동차세 납부 내역이 5년 이내의 일시납인 차량의 마지막 자동차세 납부액을 최대 예상 환급액으로 반환합니다.', () => {
    const taxPayer = new TaxPayer({
      possessedAutomobiles: [],
      automobileTaxPaymentHistory: [
        new AutomobileTaxPayment({
          plateNumber: PLATE_NUMBER,
          claimedAt: WITHIN_FIVE_YEARS,
          amount: AMOUNT,
          singlePaid: true,
        }),
      ],
    });

    const refundableAutomobiles = taxPayer.refundableAutomobiles();

    expect(refundableAutomobiles).toIncludeAutomobileWithPlateNumber(
      PLATE_NUMBER,
    );

    const refundableAutomobile = refundableAutomobiles.find((automobile) =>
      automobile.plateNumber.equals(PLATE_NUMBER),
    );

    expect(refundableAutomobile?.maximumRefundableAmount.equals(AMOUNT)).toBe(
      true,
    );
  });
});

플로우차트의 각 조건을 테스트하는 테스트 코드는 자바스크립트로 작성되어 있지만 마치 영어 문서를 읽는 것처럼 자연스럽습니다. 자연스럽게 특정 조건과 그에 대한 실행 결과를 읽어내려가면서 비즈니스 로직을 이해할 수 있습니다. 또한 테스트 코드를 실행하면 보이는 아래와 같은 테스트 케이스들은 그 자체로 문서로 기능합니다.

AutomobileTaxRefundAmountCalculator
  ✓ 보유 중인 차량을 제외합니다. (1 ms)
  ✓ 마지막 자동차세 납부 내역이 5년 이전인 차량을 제외합니다.
  ✓ 마지막 자동차세 납부 내역이 일시납이 아닌 차량을 제외합니다.
  ✓ 현재 보유 중이지 않으며, 마지막 자동차세 납부 내역이 5년 이내의 일시납인 차량의 마지막 자동차세 납부액을 최대 예상 환급액으로 반환합니다.

비즈니스 로직이 변경되는 경우에는 테스트 코드를 수정하지 않으면 테스트 케이스가 실패하므로 비즈니스 로직과 그에 대한 테스트 코드가 항상 일치하는 것을 보장할 수 있습니다.

2. 복잡한 명세의 설명과 구현

Relay에서 만든 GraphQL을 위한 pagination 방식인 connection은 그 명세가 상당히 복잡합니다. 저는 이 명세를 서로 다른 datasource에 대해 여러번 구현해 보았지만, 구현할 때마다 명세를 다시 살펴보고 이해하는데 시간을 써야 했습니다. 복잡한 명세를 구현했다고 하더라도, 엣지 케이스가 상당히 많아 제대로 구현 되었는지 확인해 보기도 어렵습니다. 엣지 케이스 하나를 놓쳤다가 운영 환경에서 문제가 발생한 적도 있었습니다.

이러한 경우 명세 자체를 테스트 코드로 먼저 작성한 후, 구현 코드를 작성하면 구현에 아주 큰 도움이 됩니다. 구현 코드를 짜고 실패한 테스트 케이스를 확인하고 해당 케이스에 관련된 코드를 수정하는 것이, 구현 코드 한줄 한줄을 짤 때마다 모든 케이스들에 어떤 영향을 끼치는지에 대해 고민하는 것보다 훨씬 효율적이기 때문입니다.

아래 테스트 코드는 datasource(CompanySearchFacade)를 mocking하여 항상 0, 1, 2를 ID로 가진 업체 목록을 반환하도록 하였습니다. Datasource를 고정한 뒤 connection argument를 변경해 가며 그 때마다 반환 되어야 할 결과와 일치하는지를 테스트 하였습니다.