Test Coverage를 유지하는 방법

June 22, 2021

coverage
from https://jeroenmols.com/blog/2017/11/28/coveragproblem/

어떤 코드를 테스트 해야할까? — Jest의 활용

코드 파일을 새로 만들거나 수정할 때 어떻게 테스트 코드를 작성해야 하는지 사실 감이 잘 오지 않는데요 커버리지 하락은 분기처리 (if-else) 가 되어있는 부분에서 적당한 테스트 코드가 없을 때 많이 발생합니다. 다음 예를 들어보겠습니다.

import { useEffect, useRef } from "react";

// https://overreacted.io/making-setinterval-declarative-with-react-hooks/
function useInterval(callback, delay, maxCount, onFinish) {
  const savedCallback = useRef();
  const callCount = useRef();
  const intervalId = useRef();

  // Remember the latest function.
  useEffect(() => {
    callCount.current = 0;
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  useEffect(() => {
    function tick() {
      callCount.current += 1;
      if (maxCount && callCount.current === maxCount) {
        clearInterval(intervalId.current);
        onFinish?.();
      }
      savedCallback.current();
    }
    if (delay !== null) {
      intervalId.current = setInterval(tick, delay);
      return () => clearInterval(intervalId.current);
    }
  }, [delay, maxCount, onFinish]);
}

export default useInterval;

조금 복잡해보이긴 하지만 window.setInterval 을 통해 콜백함수를 주기적으로 실행시키는 React 커스텀훅입니다. 추가적으로 maxCount를 받아서 최대 실행횟수를 제한 할 수도 있고, onFinish 콜백함수도 인자로 넘겨 줄 수 있네요. Jest 로 다음처럼 테스트 코드를 작성하고 모든 코드가 잘 테스트 되는지 확인해보겠습니다.

/**
 * @jest-environment jsdom
 */

// window를 사용한다고 jest에게 알려줍니다.

import React from "react";
import useInterval from "./useInterval";

jest.spyOn(React, "useRef").mockImplementation(() => ({ current: {} })); // React 라이프 사이클을 mocking합니다.
jest.spyOn(React, "useEffect").mockImplementation((f) => f());
describe("useInterval", () => {
  const mockFunction = jest.fn();
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.clearAllMocks();
    jest.clearAllTimers();
  });

  it("useInterval 콜백이 interval에 맞게 실행된다.", () => {
    useInterval(mockFunction, 1500);
    jest.advanceTimersByTime(3000);
    expect(mockFunction).toHaveBeenCalledTimes(2);
  });
});
yarn test --coverage

— coverage 옵션을 주면 jest가 친절하게 어떤코드가 테스트 되지 않고 있는지 알려줍니다.

|-------------------
| Uncovered Line #s
|-------------------
| 20-21, 25
|-------------------

jest에서 출력한 일부만 복사해 왔습니다. 예상대로 if{} (20–21, 25)구문에서 테스트가 안되고 있다고 말해주네요. 그럼 다음처럼 onFinish와 maxCount를 useInterval 파라미터로 넘겨서 테스트 하고

describe('useInterval', () => {
  ...
  it('OnFinish, maxCount가 존재한다면', () => {
    const mockOnFinish = jest.fn();
    const maxCount = 4;
    useInterval(mockFunction, 10, maxCount, mockOnFinish);
    jest.advanceTimersByTime(3000);
    expect(mockFunction).toHaveBeenCalledTimes(4);
  });
});

delay 를 null로 넣어줘서 테스트가 좀 더 명확해지도록 바꿔보겠습니다.

describe('useInterval', () => {
  ...
  it('delay가 없다면', () => {
    const mockOnFinish = jest.fn();
    const maxCount = 4;
    useInterval(mockFunction, null, maxCount, mockOnFinish);
    jest.advanceTimersByTime(3000);
    expect(mockFunction).not.toBeCalled();
  });
})
clear-coverage
깨끗한 uncovered Line

uncovered Line 이 모두 사라지고 커버리지가 100%가 되었습니다.🌟

리액트 컴포넌트를 테스트 할 경우엔 특정 컴포넌트의 콜백 prop이 실행 되지 않을 때 테스트의 누락이 발생 할 수 있습니다. 다음 예시는 간단하게 작성해봤습니다.

import { useState } from "react";
import { useDispatch } from "react-redux";
import { show } from "core/App/LoginModal/redux";

const SomeComponent = (props) => {
  const [showLogin, setShowLogin] = useState(false);
  const dispatch = useDispatch();

  return (
    <>
      <Footer defaultPath="/aiscore/resume" />
      <AlertModal
        isError={false}
        isOpen={showLogin}
        handleConfirm={() => {
          setShowLogin(false);
          dispatch(show());
        }}
        handleCloseModal={() => {
          setShowLogin(false);
        }}
      />
    </>
  );
};
|-------------------
| Uncovered Line #s
|-------------------
| 15-21
|-------------------
/**
 * @jest-environment jsdom
 */
import React from "react";
import { configure, shallow } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { show } from "core/App/LoginModal/redux";
import * as reactRedux from "react-redux";

import SomeComponent from ".";

jest.mock("core/App/LoginModal/redux");
jest.spyOn(reactRedux, "useDispatch").mockImplementation(() => jest.fn());

configure({
  adapter: new Adapter(),
});

describe("SomeComponent.js", () => {
  const context = {
    t: jest.fn((x) => x),
  };

  beforeEach(() => {
    jest.clearAllMocks(); // 테스트케이스가 끝나면 mocking 함수 호출을 초기화
  });

  it("AlertModal의  handleConfirm을 누르면 show가 호출된다", () => {
    const wrapper = shallow(<SomeComponent />, {
      context,
    });
    const Modal = wrapper.find("AlertModal");
    Modal.props().handleConfirm();
    expect(show).toBeCalled();
  });

  it("AlertModal의  handleCloseModal을 누르면 모달이 닫힌다", () => {
    const wrapper = shallow(<SomeComponent />, {
      context,
    });
    const Modal = wrapper.find("AlertModal");
    Modal.props().handleCloseModal();
    chaiExpect(Modal.props().isOpen).to.be.false;
  });
});

여기서는 적절하게 필요한 함수를 mocking 하고 SomeComponent를 shallow render한 뒤 특정 이벤트를 발생시켜 각 AlertModal의 콜백prop이 적절히 불렸는지 테스트 했습니다.

  1. 위에서 말한 상황 말고도 특정 조건에서 useState 로 정의해준 setter 함수가 잘 호출 되었는지
  2. 다음과 같이 특정 조건에서만 UI가 그려지는지 등이 모두 테스트가 필요한 코드가 될 수 있습니다.
{
  isLoading && <Loading color="red" />;
} // isLoading을 바꾸는 테스트가 필요

이 역시 --coverage 옵션을 활용해 어떤 라인이 covered 되지 않았는지 확인가능합니다.

테스트 커버리지 100%? — codecov의 활용

위에서 설명한 코드는 테스트 코드 작성을 설명하기 위한 최적의 코드입니다. 다음과 같은 경우를 생각한다면

  1. 회사 안에서 큰 규모의 스프린트
  2. 배포일정을 맞추기 위한 테스트코드 미작성
  3. Redux 또는 Context API 가 붙은 리액트 컴포넌트 테스트의 복잡성
  4. 아직 jest에서 지원하지 않은 브라우저 기능들(IndexedDB 등)

테스트 커버리지를 100% 만드는것은 굉장히(x1000) 힘든 일입니다.

sad
아 커버리지 떨어졌다...

원티드 프론트엔드팀은 테스트 도구로 Jestenzyme 을 사용하고 있는데, 이런점 (Trade-Off)을 보완하기 위해 codecov 를 Github과 연동해서 사용하고 있습니다.

codecov를 활용하면 테스트코드 커버리지의 차이(diff)를 더 쉽고 명확하게 볼 수 있고, 내가 작성한 코드가 얼마나 프로젝트 테스트코드에 영향을 미치는지 확인 할 수 있습니다.

codecov
코드머지로 +0.49%의 커버리지 증가
  • Hits — test suite에 적절하게 테스트 된 코드
  • Misses — test suite에 의해 테스트 되지 않은 코드

위 리포트를 보면 전체적으로 테스트 코드 커버리지가 증가 했지만 누락된 코드라인도 많다는 걸 알 수 있네요.

codecov가 제공해주는 리포트 안 링크에 들어가면 다음과 같이 더 자세한 코드상태를 시각적으로 확인 할 수 있습니다.

codecov-hits
Hits는 파랑색으로 표시
codecov-miss
misss는 빨간색으로 표시
codecov-info
커밋에 따른 커버리지 변화도 볼 수 있습니다.
codecov-metric
codecov 에서 알려주는 3가지 metric

codecov 에서 알려주는 3가지 metric은 다음과 같습니다.

  1. 본 커밋 기준으로 프로젝트의 총 커버리지 %
  2. 본 커밋에서 수정된 파일만으로 계산된 커버리지 %
  3. 다른 커밋과 비교했을 때 프로젝트의 커버리지가 얼마로 변경되는지

위를 잘 활용한다면 어느 테스트 코드에 보완이 필요한지 알 수 있기 때문에 비교적 쉽게 테스트 커버리지를 올릴 수 있게 됩니다.

codecov.yml 파일 설정을 다음처럼 해서 커버리지를 떨어트리는 커밋은 머지(Merge)하지 않도록 설정할 수 있습니다.

{
  "require_ci_to_pass": true
}

(커버리지를 유지하는 극단적인 방법…😭)

같이 보면 좋은 링크들과 참조한 페이지

  1. 원티드 프론트엔드팀 안드레님이 작성하신 테스트코드 관련 기본지식
  2. enzyme공식문서
  3. jest공식문서
이전 - serverless-next.js deploy