[Javascript] Jest 사용법과 단위 테스트 커버리지를 올리면서 보였던 것들

배준형·2024년 1월 31일
0

서문

안녕하세요. 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 하고 있는 배준형입니다.

잡다 팀에서는 Jest, React Testing Library 두 개의 라이브러리를 사용하여 단위 테스트, 컴포넌트 테스트 코드가 작성되어 있습니다. 그런데, 테스트 코드가 오래되었기도 하고, 회사 내부에서 스쿼드 단위로 조직 변경 / 인원 재배치, 테스트 코드에 대한 컨벤션이 없어서 점점 테스트 코드를 작성하지 않고 방치하게 되었습니다.

저희 팀은 QA 엔지니어가 없으며, 개발 후 자체 테스트를 거쳐서 배포를 진행하고 있습니다. 그 과정에서 발견되지 못한 버그들도 배포된 경험도 있었는데요. 치명적인 버그는 아니었지만, 이러한 상황을 방지하기 위해 테스트 코드가 필요하다고 생각했고, 테스트 커버리지를 높이는 것을 목표로 테스트 코드 작성을 진행했습니다.

물론 테스트 커버리지가 높다고 하여 모든 경우에 대한 테스트가 진행되었다는 것을 의미하지는 않습니다. 상황이나 함수의 내용 등에 따라 use-case, edge-case 등을 다루도록 노력해야 하지만, 아예 없는 수준의 테스트 코드를 작성하는 것이므로 커버리지를 목표로 작업하여도 괜찮을 것 같습니다.


Jest 커버리지 확인

Jest를 사용할 때 테스트 커버리지를 확인하려면 jest.config.ts(또는 jest.config.js) 파일에서 collectCoverage 값을 true로 설정해 주면 됩니다.

jest.config.ts

import type { Config } from "@jest/types";

const config: Config.InitialOptions = {
  // ...
  collectCoverage: true,
};

export default config;
  • collectCoverage: true / false 값으로, true면 코드 커버리지 정보를 수집하고 출력합니다.
    • 코드 커버리지는 함수, 블록, 파일 등이 얼마나 실행되었는지를 보여줍니다.
    • 테스트에 영향을 미치지는 않습니다.

위와 같이 설정하고 jest test를 돌리면 아래와 같이 터미널에 커버리지 정보가 뜨게 됩니다.

위 캡쳐는 잡다 프론트엔드 팀 내부에서 이미 작성한 테스트 코드 커버리지인데요. 테스트 파일도 7개밖에 되지 않고, 전체적으로 커버리지가 모두 20-30% 정도로 낮음을 알 수 있습니다. 아예 테스트 코드를 작성하지 않았다면 커버리지에 집계되지 않습니다.


간단한 함수 테스트 코드 작성

우선 number 타입 관련한 유틸 함수 테스트 코드 커버리지를 올려보겠습니다. 왼쪽부터 순서대로 stmts, branch, funcs, lines, uncovered line 퍼센트 값인데요. 각각 무엇을 의미하는지는 아래와 같습니다.

  • stmts: 문(statement)의 커버리지 비율.
  • branch: 분기(if, switch 등)의 커버리지 비율.
  • funcs: 함수의 커버리지 비율.
  • lines: 라인의 커버리지 비율.
  • Uncovered Line #s: 커버되지 않은 라인 번호들.

위 정보에 따르면 11 line, 21~55 line, …, 126~155 line 테스트 커버리지가 집계되지 않았다는 의미입니다. 따라서 이 라인들을 중심으로 테스트 코드를 작성하면 커버리지를 높일 수 있을 것입니다.

우선 11 line을 살펴보면 아래와 같은 코드가 존재합니다. 이 함수에 대한 테스트 코드 작성 후 커버리지를 다시 살펴보겠습니다.

numberFormat.ts

function formatPad(num: number): string {
  return num.toString().padStart(2, '0');
}

numberFormat.spec.ts

describe('formatPad', () => {
  it('한 자리 수에 0을 붙인다', () => {
    expect(formatPad(1)).toBe('01');
  });

  it('두 자리 수 이상일 때 0을 붙이지 않는다', () => {
    expect(formatPad(11)).toBe('11');
    expect(formatPad(111)).toBe('111');
  });
});

branch 비율 외 나머지 퍼센트가 모두 오르고 uncovered line에서 11이 사라졌습니다. 이런 식으로 커버리지를 올리면 될 것 같습니다.


다만, formatPad 함수의 경우 2자릿수 미만에서만 0을 붙이고, 2자릿수 이상일 때는 아무 변화 없이 문자열로만 변환하고 있는데, 2자릿수 미만에 대해서만 테스트 코드를 작성해도 커버리지는 동일하게 잡힙니다.

numberFormat.spec.ts

describe('formatPad', () => {
  it('한 자리 수에 0을 붙인다', () => {
    expect(formatPad(1)).toBe('01');
  });

  // 두 자리 수 이상에 대한 테스트 코드를 삭제했음.
});

결과는 이전과 동일합니다. 결국 사용자가 적절한 use-case, edge-case 등을 잘 확인해야 하고, 커버리지가 높다고 해서 모든 경우에 대한 테스트가 이루지지 않음을 확인할 수 있습니다.


Jest 라이프 사이클 활용하기

describe 함수 내부에서 테스트 코드를 작성할 때 라이프 사이클 함수들을 사용할 수 있습니다.

describe('example test suite', () => {
  beforeAll(() => {
    console.log('beforeAll');
  });

  afterAll(() => {
    console.log('afterAll');
  });

  beforeEach(() => {
    console.log('beforeEach');
  });

  afterEach(() => {
    console.log('afterEach');
  });

  it('example test case 1', () => {
    console.log('test case 1');
  });

  it('example test case 2', () => {
    console.log('test case 2');
  });
});
  • beforeEach: 각각의 테스트 케이스(Test Case)가 실행되기 전에 실행되는 함수
  • afterEach: 각각의 테스트 케이스(Test Case)가 실행된 후에 실행되는 함수
  • beforeAll: 모든 테스트 케이스(Test Case)가 실행되기 전에 한 번만 실행되는 함수
  • afterAll: 모든 테스트 케이스(Test Case)가 실행된 후에 한 번만 실행되는 함수
beforeAll
beforeEach
test case 1
afterEach
beforeEach
test case 2
afterEach
afterAll

일반적으로 Jest에서 라이프 사이클 메서드들은 테스트 전과 후에 리소스를 설정하고 정리하기 위해 사용합니다. 또한 테스트 간 데이터 오염을 막기 위해 사용하기도 합니다.


의존성 주입하기

라이프 사이클 메서드들은 반복적으로 사용되는 변수를 미리 선언하거나 의존성 주입에 활용할 수 있습니다. 테스트 실행 전/후에 특정 값을 설정하거나 초기화하는 용도로 사용될 수 있습니다.

예를 들어, localStorage나 document와 같은 객체의 메서드, setTimeout과 같은 API를 사용하는 함수는 테스트하기 어려운 경우가 있습니다. 이럴 때 jest.fn()jest.spyOn() 과 라이프사이클 메서드를 함께 사용하면 의존성을 낮추고 테스트를 쉽게 작성할 수 있습니다.

  • jest.fn() : 함수를 모킹하여 호출 파라미터, 호출 횟수 등을 확인할 수 있습니다.
  • jest.spyOn() : 함수의 호출을 추적하고 사용 방식을 확인할 수 있습니다.

예제 코드와 함께 살펴보겠습니다.

LocalStorage.ts

function setItem(key: string, value: string) {
  try {
    localStorage.setItem(key, JSON.stringify(newValue));
  } catch (e) {
    console.error(e);
  }
}

LocalStorage.spec.ts

describe('localStorage Util', () => {
  const key = 'key';
  const value = 'value';

  describe('setItem 시', () => {
    beforeEach(() => {
      setItem(key, value);
    });

    it('localStorage 에 item 이 추가된다.', () => {
      expect(getItem(key)).toEqual(value);
    });
  });
});

위 테스트 코드는 try 문에 대한 경우만 테스트하고 있고, catch 문의 예외 처리를 커버하지 않습니다. 이를 커버하려면 setItem()에 의도적으로 에러를 발생시켜야 합니다.


describe('setItem', () => {
  const mockSetItem = jest.fn();
  const originalSetItem = window.localStorage.setItem;

  // 각각의 테스트 실행 전에 호출됩니다.
  beforeEach(() => {
    Object.defineProperty(window, 'localStorage', {
      value: {
        setItem: mockSetItem,
      },
    });
    mockSetItem.mockImplementation(() => { // setItem을 항상 Error를 throw 하도록 mocking합니다.
      throw new Error('error');
    });
  });

  // 각각의 테스트 실행 후에 호출됩니다.
  // 모킹을 초기화 해줍니다.
  afterEach(() => {
    window.localStorage.setItem = originalSetItem;
    mockSetItem.mockClear();
  });

  it('setItem 에러 발생 시 console.error 가 호출된다.', () => {
    const spy = jest.spyOn(console, 'error'); // console.error를 spyOn 하여 호출 여부를 확인합니다.
    LocalStorageUtil.setItem(key, value);
    expect(spy).toBeCalledWith(new Error('error'));
  });
});

테스트를 실행하기 전 beforeEach를 이용해 localStorage의 setItem을 모킹합니다. 모킹된 setItem은 항상 error를 throw 하도록 설정해 두고, 테스트가 끝나면 다른 테스트에 영향이 없도록 모킹된 환경은 다시 원복합니다.

Error 발생 시 console.error를 호출하기 때문에 이를 확인하기 위해 console.error를 spyOn 하여 호출되었는지, 어떤 전달인자와 함께 호출되었는지, 몇 번 호출되었는지 확인합니다. 이 경우에는 단순히 console.error(e) 형태로 출력만 하고 있으므로 toBeCalledWith 메서드를 사용해 주었습니다. 이렇게 라이프사이클 메서드와 모킹을 활용하면 예외 처리에 대한 테스트 코드를 작성할 수 있습니다.


Class Private 메서드 테스트하기

테스트 커버리지를 올리기 위해서 private 메서드들을 테스트해야 하는 경우도 생깁니다. 잡다 팀에서도 class로 되어있는 추상화된 클래스에 private 메서드들이 존재했는데요. 이런 부분들을 테스트하려면 아래의 방법대로 수행할 수 있습니다.

  • private signature를 protected로 바꾼 후 상속받는 테스트용 클래스를 만들어 테스트를 수행한다.
  • Array Access를 이용하여 private 메서드에 접근한다.

private signature 대신 protected로 바꾸기

export class RequestClient {
  private get isRefreshing() {
    return this.client.length > 0;
  }

  constructor() {
    // ...
  }
}

잡다에는 axios 등을 다루는 RequestClient Class가 존재해 예시 코드를 가져왔습니다. private 요소를 제외한 나머지 요소들에 대한 테스트 코드를 작성한 후에 커버리지를 확인해 보면 당연하게도 isRefreshing 부분에 대한 커버가 집계되어 있지 않습니다. 이 부분을 protected로 바꿔줍니다.

protected는 외부에서 접근이 불가능하고 상속받는 자식 클래스에서 접근이 가능한 접근 제어자입니다. 따라서 테스트를 위해서는 RequestClient를 상속받는 테스트 클래스를 만들어야 합니다.

export class RequestClient {
  protected get isRefreshing() { // private 대신 protected로 변경합니다.
    return this.client.length > 0;
  }

  constructor() {
    // ...
  }
}

// protected isRefreshing에 접근하기 위해 테스트용 클래스를 만듭니다.
export class TestRequestClient extends RequestClient {
  testIsRefreshing() {
    return this.isRefreshing;
  }
}
describe('RequestClient', () => {
  let testRequestClient: TestRequestClient;

  beforeEach(() => {
    testRequestClient = new TestRequestClient();
  });

  it('isRefreshing - client가 비어있으면 false를 반환한다.', () => {
    const result = testRequestClient.testIsRefreshing();

    expect(result).toBeFalsy();
  });
});

RequestClient를 상속받는 테스트 클래스를 만든 후 해당 클래스에서 private 항목들에 접근하고, 테스트 클래스의 메서드들은 public으로 외부에서 접근 가능하게 설정합니다.

이렇게 되면 테스트를 수행할 수 있습니다.


Array Access를 이용하기

protected로 바꿔서 테스트를 수행하려면 결국 기존 Class를 수정해야 되긴 합니다. 이런 식으로 signature 수정을 하고 싶지 않다면 Array Access를 활용해서 테스트를 수행할 수도 있습니다.

describe('RequestClient', () => {
  let requestClient: RequestClient; // 테스트용 클래스는 없어도 됩니다.

  beforeEach(() => {
    requestClient = new RequestClient();
  });

  it('isRefreshing - client가 비어있으면 false를 반환한다.', () => {
    const result = requestClient['isRefreshing']; // Array Access로 접근합니다.

    expect(result).toBeFalsy();
  });
});

protected 또는 Array Access 두 가지 방법으로 private 요소에도 접근하여 테스트 코드를 작성할 수 있습니다.


커버리지를 높이는 과정에서 발견된 수정 사항들

사용 중인 유틸 함수의 Edge-Case 발견

기존에 사용 중이던 유틸 함수에 테스트 코드를 추가하는 것이 실제로 도움이 될지 의문일 수 있습니다. 테스트 코드를 작성하고 있지만, 과연 효용이 있을까?? 싶은 마음이 들기도 합니다.

그런데, 테스트 코드를 구현하는 과정에서 잘 사용 중이던 기존 코드의 예외 상황이나 에지 케이스를 발견할 수도 있습니다.

numberFormat.ts

function formatMonthToYYMMText(month: number) {
  if (month < 1) return '';
  else if (month < 12) return `${month}개월`;
  else if (month > 12) {
    const yearText = `${Math.floor(month / 12)}`;
    const monthText = month % 12 ? `${month % 12}개월` : '';

    return monthText ? `${yearText} ${monthText}` : yearText;
  }

  return '';
}

이 함수는 잡다 서비스 내에서 활용 중인 함수로 개월 수를 인자로 받아서 년, 개월 문자열로 표시해 주는 역할을 수행합니다. 만약 14를 input으로 넣었다면 반환되는 값은 1년 2개월이 되겠죠. 물론 더 좋은 함수로 개선할 수 있지만, 여기선 테스트 코드 설명을 위해 있는 그대로의 코드를 가져와서 보여드리는 것이니 참고만 해주시면 감사하겠습니다.

저는 해당 함수를 보고 아래와 같이 테스트 코드를 작성했습니다.

numberFormat.spec.ts

describe('formatMonthToYYMMText', () => {
  // 생략

  it('month가 12 이상이면 "y년 m개월"을 반환한다', () => {
    expect(NumberFormat.formatMonthToYYMMText(12)).toBe('1년');
    expect(NumberFormat.formatMonthToYYMMText(13)).toBe('1년 1개월');
    expect(NumberFormat.formatMonthToYYMMText(23)).toBe('1년 11개월');
    expect(NumberFormat.formatMonthToYYMMText(24)).toBe('2년');
    expect(NumberFormat.formatMonthToYYMMText(25)).toBe('2년 1개월');
  });

  // 생략
});

테스트 결과

대부분 통과했지만, 12를 넣었을 때 빈 문자열이 나와야 하는데 1년이라고 작성해서 실패했다고 합니다. 제가 생각했을 땐 12를 넣었을 때 빈 문자열이 나오는 게 아니라 1년이라고 나와야 맞는 것 같은데 구현이 잘못되었을까요?

여기서 빈 문자열이 나온 건 의도한 것은 아닐 겁니다. 만약 개월 표시가 없으면 도 표시해 주지 않는 것으로 의도한 것이라면 24를 넣었을 때도 빈 문자열이 나와야 하겠죠. 그런데 날짜를 그런 식으로 사용하진 않을 것 같아요. 24를 넣으면 2년이 잘 나오기도 하고요.


numberFormat.ts

formatMonthToYYMMText(month: number) {
  if (month < 1) return '';
  else if (month < 12) return `${month}개월`;

  // 여기 조건문이 문제입니다.
  else if (month > 12) {
    const yearText = `${Math.floor(month / 12)}`;
    const monthText = month % 12 ? `${month % 12}개월` : '';

    return monthText ? `${yearText} ${monthText}` : yearText;
  }

  return '';
}

다시 살펴보니 if 문 조건에 month가 12인 경우가 빠져있습니다. 결국 모든 if 문을 지나치고 마지막 코드인 return '' 문을 만나서 빈 문자열을 반환한 것이죠. 12 값을 넘긴다면 빈 문자열이 아니라 1년이 반환되는 것이 맞는 것 같습니다. 해당 부분은 else if 조건문 내부를 month >= 12 로 수정했습니다.


사실 결과만 보면 = 기호 하나 추가한 정도로 별것 아니지만, 이런 식으로 테스트 코드를 작성하다 보면 무언가 개발자가 의도하지 않은 코드들을 발견할 수 있습니다. 저는 이런 케이스가 실제 에러와 비슷한 수준으로 중요하다고 봅니다.

에러가 발생하면 어디서 발생했는지 추적이 가능하지만, 에러 없이 잘못된 출력이 발생하는 경우 문제의 존재조차 인지하기 어렵습니다.

예를 들어 위 함수에 11, 13 입력에 대해 11개월, 1년 1개월과 같이 올바른 출력이 나오는 경우, 12 입력에 대한 문제의 존재를 확인하기 어려울 수 있죠.

따라서 테스트 케이스를 충분히 구현하는 것이 중요합니다. 테스트 코드 작성을 통해 에러 뿐만 아니라 잠재된 오동작 가능성까지 발견할 수 있다고 볼 수 있습니다.


오타 수정

테스트 코드를 작성하다 보면 기존 코드의 오타를 발견할 수도 있습니다.

Date 관련 함수 중 Date 객체를 매개변수로 하여 현재 시간과 비교했을 때 방금 전, n초 전, n분 전 등의 문자열로 반환하는 함수가 존재합니다.

해당 함수의 테스트 중 방금 전을 반환하는 경우에 대한 테스트 코드가 실패했는데, 실패 원인을 통해 오타를 발견했습니다.

물론, UX 라이팅 관점에서 의도적으로 방금전 이라고 표기했을 수도 있습니다. 확인 결과 의도한 것은 아니고, UI 상 크기가 넘어가는 상황도 아니며, 다른 케이스인 n초 전 과 같은 케이스는 띄어쓰기가 되어 있으므로 방금 전 이라고 띄어 쓰는 것이 맞을 것 같습니다.


테스트 코드 작성 후 커버리지 결과

우선 작성 중이던 단위 테스트만 모두 작성해 주었습니다. Uncovered Line이 곳곳에 존재하는데요. 저 부분들은 Type Guard 관련된 부분으로 논리적으로 도달 불가능한 영역에 대한 것이라 작성하지 않았습니다.

결과적으로 20~30% 수준의 테스트 커버리지를 80~90% 수준으로 올렸습니다. 그 과정에서 엣지 케이스, 오타 등의 수정도 있어서 커버리지 올리기 이상으로 작업이 된 것 같습니다.


정리

저는 프론트엔드 개발자에게 테스트 코드가 진정으로 필요한가에 대한 의문을 가지고 있었습니다. Storybook을 통해 컴포넌트 테스트가 가능하기도 하고, 단위 테스트 코드 작성도 결국 작성자가 생각할 수 있는 범위까지만 커버할 수 있는 것은 아닐까 했습니다.

그럼에도 제가 속해있는 조직에 QA 팀이 없고, 운영 환경 배포 전에 Sprint 작업물은 기획, 디자이너, 개발자에 의해 이루어지니 조금이라도 안정성 확보를 위해 테스트 코드가 필요하다고 느꼈습니다.

테스트 코드를 작성하니 예상하지 못한 기존 코드들의 문제점들도 더 늦기 전에 발견할 수 있어서 다행이었고, 아직 작성되지 않은 단위 테스트도 지속적으로 작성해서 안정성을 높이는 것이 좋겠다는 생각이 들었습니다.

이건 단위 테스트 코드에 대한 생각이었고, 이후에 컴포넌트 테스트도 도입하게 된다면 컴포넌트를 테스트 단위로 쪼개서 작게 만들 수 있는 기준이 되면서 테스트 시나리오를 통해 검증하기 힘들었던 부분들까지 수월하게 테스트할 수 있게 될 것 같습니다.

사실 기존 코드 대비 20~30% → 80~90% 올렸지만, 전체 코드에 비해선 매우 적은 비율이긴 합니다. 이번 기회에 스프린트 중간에 단위 테스트를 꾸준히 작성하고, 이를 pipeline 등과 연동해서 테스트에 문제가 있다면 빌드 실패, 테스트가 문제없다면 빌드 성공하도록 세팅해서 안정성을 더 높일 수 있을 것 같습니다.

profile
프론트엔드 개발자 배준형입니다.

0개의 댓글