[Vanilla JS 문서편집기 설명 및 개선] 9. Jest를 이용해 테스트 코드 적용하기

도입 목적

지난번에 이어 Rich한 에디터를 만들어야하지만 그 전에 테스트 코드를 도입하기로 했습니다. 🎉

이유는 Rich 에디터를 만들면서 코드도 너무 복잡하게 꼬이고, 버그 하나를 고치면 버그가 새로운 곳에서 생기는 문제가 자주 발생했기 때문입니다. 버그를 고치고 일일히 모든 기능을 테스트하기엔 자원의 낭비가 심했고 이에 따라 리팩토링을 하는데 엄두도 나지 않았습니다.

하지만 테스트 코드를 적용한다면? 오류가 나도 테스트에서 알려주기 때문에 안심하며 리팩토링이 가능합니다!

이런 목적으로 도입하게 되었고 연습삼아 Component 템플릿의 테스트 코드를 작성해보았습니다.

 

제가 Jest를 선택 한 이유와 테스트 코드의 장점, 환경 구축하는 법은 Jest를 Vite 프로젝트에 적용하기에 자세히 설명했습니다. Component 템플릿이 궁금하다면 4. 컴포넌트 템플릿 만들기5. VirtualDOM처럼 렌더링하기에 설명되어있습니다.

각 링크를 참고해주세요!

 

Jest 작성법

간단하게 제가 Jest를 사용한 과정을 설명하겠습니다.

1. __test__ 폴더에 OO.test.js 이름으로 테스트 파일 생성하기

Jest를 Vite 프로젝트에 적용하기를 보고 오셨다면 __test__ 폴더가 존재할 텐데 이 내부에 OO.test.js 이름으로 테스트 코드를 작성한 뒤 npm test로 테스트를 진행할 수 있습니다.

이 때 __test__내부에 있는 폴더도 전부 실행되니 폴더도 생성해도 됩니다.

2. 테스트 구성요소

테스트는 Jest가 제공하는 test 함수로 정의합니다. test 함수는 두 개의 매개변수를 받는데, 

test(테스트명, 테스트 함수);

첫 번째 인수 테스트명에는 테스트 내용을 잘 나타내는 제목을 할당합니다.

test("Component 인스턴스를 만들 수 있는지 테스트");

두 번째 인수 테스트 함수에는 단언문을 작성하는데, 단언문은 검증값이 기댓값과 일치하는지 검증하는 문입니다.

test("Component 인스턴스를 만들 수 있는지 테스트",() => {
	expect(검증값).toBe(기댓값);
});

단언문은 expect 함수와 덧붙이는 매처(matcher)로 구성되어 있고, Jest는 여러개의 매처를 제공합니다.

단언문 : expect(검증값).toBe(기댓값)
매처 : toBe(기댓값)

3. 테스트 그룹 작성

연관성 있는 테스트들을 그룹화하고 싶을 때는 describe 함수를 사용합니다. test 함수는 중첩시킬 수 없지만 describe 함수는 중첩이 가능하기 때문에 다음과 같이 작성할 수 있습니다.

describe("Component Unit Test", () => {
    describe("eventHandler Unit Test", () => {
        test("가상 DOM 요소의 Attribute가 그대로일 때 업데이트 되는지 테스트", () => {
          virtualNode.setAttribute("data", "data");
          realNode.setAttribute("data", "data");

          updateNode($target, realNode, virtualNode);

          expect(realNode.getAttribute("data")).toBe("data");
        });
        test("가상 DOM 요소의 Attribute가 추가되었을 때 업데이트 되는지 테스트", () => {
          virtualNode.setAttribute("data-test", "value");

          updateNode($target, realNode, virtualNode);

          expect(realNode.getAttribute("data-test")).toBe("value");
        });
    });
});

 

4. 테스트 실행하기

테스트를 실행하는 방법은 2가지 입니다.

  1. npm test로 전체 테스트 실행하기
  2. 파일 하나만 실행 또는 Jest Runner로 부분만 테스트 실행하기

파일 하나만 실행하는 방법은 아래와 같습니다.

npm test '__test__/core/component.test.js'

하지만 번거롭기 때문에 VSCode 사용자라면 Jest Runner를 이용하는걸 추천합니다.

etc-image-0
etc-image-1

코드에서 Run | Debug를 선택할 수 있습니다. (편리하다!)

 

5. 생성한 테스트 코드

제가 직접 생성한 테스트 코드입니다. 

import Component from "@core/Component";
import { updateNode } from "@core/Component";

describe("Component Unit Test", () => {
  let $target;
  let component;

  beforeEach(() => {
    $target = document.createElement("div");
    document.body.appendChild($target);

    component = new Component({ $target, props: {} });
  });

  afterEach(() => {
    document.body.innerHTML = "";
  });

  test("Component 인스턴스를 만들 수 있는지 테스트", () => {
    expect(component).toBeInstanceOf(Component);
    expect(component.$target).toBe($target);
    expect(component.props).toEqual({});
  });
  test("초기화 단계에서 setup, render, setEvent가 호출 되는지 테스트", () => {
    const setupSpy = jest.spyOn(Component.prototype, "setup");
    const renderSpy = jest.spyOn(Component.prototype, "render");
    const setEventSpy = jest.spyOn(Component.prototype, "setEvent");

    new Component({ $target, props: {} });

    expect(setupSpy).toHaveBeenCalledTimes(1);
    expect(renderSpy).toHaveBeenCalledTimes(1);
    expect(setEventSpy).toHaveBeenCalledTimes(1);
  });
  test("template 메소드가 반환하는 HTML 테스트", () => {
    component.template = () => "<div>template HTML test</div>";
    component.render();
    expect($target.innerHTML).toBe("<div>template HTML test</div>");
  });
  test("state가 업데이트 될 때 re-render가 되는지 테스트", () => {
    component.template = () => `<div>${component.state.text || ""}</div>`;
    component.setState({ text: "Updated" });
    expect($target.innerHTML).toBe("<div>Updated</div>");
  });

  describe("eventHandler Unit Test", () => {
    let button;
    let callback;
    beforeEach(() => {
      button = document.createElement("button");
      $target.appendChild(button);
      callback = jest.fn();
      component.addEvent("click", "button", callback);
    });

    afterEach(() => {
      document.body.innerHTML = "";
    });

    test("선택자가 일치할 때 이벤트 핸들러가 올바르게 호출되는지 테스트", () => {
      button.click();
      expect(callback).toHaveBeenCalledTimes(1);
    });
    test("선택자가 일치하지 않을 때 이벤트 핸들러가 호출되지 않는지 테스트", () => {
      const unrelatedElement = document.createElement("div");
      document.body.appendChild(unrelatedElement);

      unrelatedElement.click();
      expect(callback).not.toHaveBeenCalled();
    });
  });

  describe("updateNode Unit Test", () => {
    let realNode = document.createElement("div");
    let virtualNode = document.createElement("div");

    beforeEach(() => {
      realNode = document.createElement("div");
      virtualNode = document.createElement("div");
      $target.appendChild(realNode);
    });

    afterEach(() => {
      document.body.innerHTML = "";
    });

    test("가상 DOM 요소의 Attribute가 그대로일 때 업데이트 되는지 테스트", () => {
      virtualNode.setAttribute("data", "data");
      realNode.setAttribute("data", "data");

      updateNode($target, realNode, virtualNode);

      expect(realNode.getAttribute("data")).toBe("data");
    });
    test("가상 DOM 요소의 Attribute가 추가되었을 때 업데이트 되는지 테스트", () => {
      virtualNode.setAttribute("data-test", "value");

      updateNode($target, realNode, virtualNode);

      expect(realNode.getAttribute("data-test")).toBe("value");
    });
    test("실제 DOM 요소의 Attribute가 삭제 되는지 테스트", () => {
      realNode.setAttribute("data-test", "value");
      expect(realNode.getAttribute("data-test")).toBe("value");
      expect(virtualNode.getAttribute("data-test")).toBe(null);

      updateNode($target, realNode, virtualNode);

      expect(realNode.getAttribute("data-test")).toBe(null);
    });
    test("실제 DOM 요소의 Node가 추가 되는지 테스트", () => {
      const virtualChildNode = document.createElement("div");
      virtualNode.appendChild(virtualChildNode);

      updateNode($target, realNode, virtualNode);

      const findDiv = [...realNode.childNodes].find(
        (child) => child.nodeName === "DIV"
      );
      expect(findDiv).not.toBe(undefined);
    });
    test("실제 DOM 요소의 Node가 삭제 되는지 테스트", () => {
      const realChildNode = document.createElement("div");
      realNode.appendChild(realChildNode);

      updateNode($target, realNode, virtualNode);

      const findDiv = [...realNode.childNodes].find(
        (child) => child.nodeName === "DIV"
      );
      expect(findDiv).toBe(undefined);
    });
    test("실제 DOM 요소의 Text가 다를 때 변경되는지 테스트", () => {
      const realTextNode = document.createTextNode("real");
      const virtualTextNode = document.createTextNode("virtual");
      realNode.appendChild(realTextNode);
      virtualNode.appendChild(virtualTextNode);

      updateNode($target, realNode, virtualNode);

      expect(realNode.textContent).toBe("virtual");
    });
    test("실제 DOM 요소의 Text가 같을 때 변경되지 않는지 테스트", () => {
      const realTextNode = document.createTextNode("real");
      const virtualTextNode = document.createTextNode("real");
      realNode.appendChild(realTextNode);
      virtualNode.appendChild(virtualTextNode);

      updateNode($target, realNode, virtualNode);

      expect(realNode.textContent).toBe("real");
    });
    test("실제 DOM 요소의 nodeName이 virtualNode와 다를 때 변경되는지 테스트", () => {
      const virtualAnotherNode = document.createElement("p");

      updateNode($target, realNode, virtualAnotherNode);

      const updatedNode = $target.firstChild;
      expect(updatedNode.nodeName).toBe("P");
    });
  });
});

6. 테스트 결과

etc-image-2

  • Stmts : 구현 파일에 있는 모든 구문이 적어도 한 번은 실행되었는지 나타냅니다.
  • Branch : 구현 파일에 있는 모든 조건 분기가 적어도 한 번은 실행되었는지 나타냅니다. if문, case문, 삼항연산자를 사용한 분기가 측정 대상.
  • Funcs : 구현 파일에 있는 모든 함수가 적어도 한 번은 호출되었는지 나타냅니다. export 된 함수
  • Lines : 구현 파일에 포함된 모든 라인이 적어도 한 번은 통과되었는지 나타냅니다.
  • Uncovered Line : 커버되지 않은 라인을 나타냅니다.

테스트 코드의 커버리지 목표는 프로젝트의 필요와 팀의 표준에 따라 달라질 수 있는데, 많은 프로젝트에서는 85%~90% 정도의 커버리지를 목표로 합니다. 이는 주요 기능을 충분히 테스트하면서도 실용적이고 관리 가능한 수준의 테스트를 유지할 수 있습니다.

7. Github Action에서 테스트 실행하기 

.github/workflows/OO.yml에 CI 기능을 수행할 파일을 생성합니다.

name: Jest test CI

on: push

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - name: Install dependencies
        run: npm install
      - name: Run tests
        run: npm test

push 후 PR을 생성하게 되면 다음과 같이 테스트를 진행하는 것을 볼 수 있습니다.

etc-image-3