Vitest로 단위 테스트 도전

김상민·2024년 3월 5일
1
post-thumbnail

vanilla js 서비스에 단위테스트를 도입해보자.

2달간의 소프티어 부트캠프가 끝나서 이제 면접 준비와 함께 해보고 싶었던 공부를 시작했다.
와글와글 프로젝트를 발전시키면서 테스트의 필요성을 느꼈고 테스트 코드를 작성해보고 싶었는데, 테스트에 대한 개념에 대해 교육 받은 후에 간단한 예제 테스트 작성만 해보고 프로젝트에 적용을 못해서 아쉬움이 있었다.

테스트 대상

워밍업 프로젝트로 진행한 와글와글에 테스트를 해보려고 한다.
vanilla js로 만들었기 때문에 처음 단위테스트를 도입하기 좋다고 생각했다.

참고로 와글와글은 소프티어 부트캠프에서 5일간의 프로젝트를 통해 만들어진 지하철에서 떠드는 서비스다.
프로젝트 기간이 끝나고도 팀원들과 함께 계속해서 서비스를 만들어가고 있고 지금은 기능 보완과 ts로 마이그레이션을 거친 후에 ver.2를 배포한 상태다.

⚡️ Vitest

vitest는 jest와 같이 테스트를 도와주는 라이브러리다.
빌드도구로 vite를 사용하고 있어서 vitest로 첫 단위테스트를 해보려한다.
vite를 사용하고 있다면 설정도 간단하고 사용법도 간단해서 좋은 것 같다.

⚙️ Setting

npm install -D vitest @vitest/ui
// package.json

"scripts": {
        "test": "vitest --ui",
        "test:run": "vitest run",
        "coverage": "vitest run --coverage"
    },

이렇게 하면 기본적인 테스트 세팅이 끝난다.
그리고 @vitest/ui는 아래와 같이 테스트 상황을 ui로 보여준다.
--ui를 test script에 추가해서 사용할 수 있다.

추가 세팅

테스트는 node.js환경에서 실행되기 때문에 브라우저의 DOM API에 접근할 수가 없어서 window.location이나 localstorage등에 접근하는 코드가 있다면 mocking을 해줘야하는 문제가 있다.
그래서 이를 해결하기 위해서 DOM API를 사용할 수 있게 해주는 jsdom이나 happy-dom을 사용한다.
happy-dom이 조금 더 경량화된 버전이라고 해서 이를 사용했다.

npm i -D happy-dom
// vite.config.ts
// vite 환경이 아니라면 vitest.config.ts에 아래 설정을 추가해줘도 된다.
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'happy-dom', // or 'jsdom', 'node'
  },
})

🧪 테스트 작성하기

먼저 테스트를 진행하기전에 기준과 순서를 정했다.

1. 최소단위 함수부터 테스트하기

  • 반환값이 명확히 존재하고, 다른 함수를 호출하지 않는 함수부터 테스트 한다.(의존성이 없는 함수)
  • 단위 테스트 구현을 하면서 원래 코드도 테스트 가능한 코드로 개선해나간다.(리팩토링)

2. given -> when- > then 패턴

어떤 상황에서 어떻게 동작하는지를 나타내는게 테스트코드의 핵심이다.

일관된 방식의 테스트 코드 구현을 위해서
given(테스트에 필요한 값 셋팅) -> when(실행) -> then(테스트)
방식으로 테스트를 수행한다.

이 방법이 가장 많이 쓰인다고 함.
https://martinfowler.com/bliki/GivenWhenThen.html

그래서 util 함수부터 해보기로 했다.

첫번째 테스트

테스트할 함수는 아래와 같다.
url에서 역의 id를 가져오는 함수다.

export const getStationId = (): string => {
  const { pathname } = window.location;
  const stationId = pathname.split("/")[2];
  return stationId;
};

😅 첫번째 난관..

순수함수가 아니라서 window.location에 대한 mocking이 필요했다.
(이때는 happy-dom을 몰랐음)

공식문서에서 방법을 찾아보니 stubGlobal을 사용하면 된다고 했다.
그래서 아래와 같이 테스트 코드를 작성해서 통과했다.

import { vi, describe, it, expect } from "vitest";
import { getStationId } from "./getStationId";

const mockLocation = {
    pathname: "www.waglewagle.store/station/12",
};

// Given: window.location의 pathname을 모의 설정
vi.stubGlobal("location", mockLocation);

describe("getStationId", () => {
    it("URL에서 역 ID를 추출하여 반환한다", () => {
        // When: getStationId 함수가 호출될 때
        const stationId = getStationId();
        // Then: 예상되는 역 ID가 반환되어야 한다
        expect(stationId).toBe("12");
    });

😎 happy-dom

하지만 이렇게 순수함수가 아닌 함수가 더 많을텐데 DOM API를 사용하는 코드는 전부 mocking을 한다는게 말이 안된다고 생각했다.
그래서 공식문서를 조금 더 찾아보니 environment setting에서 happy-dom을 알게 되었다.
mocking을 제거한 코드는 아래와 같다.

import { describe, it, expect, beforeEach } from "vitest";
import { getStationId } from "./getStationId";

describe("getStationId with happy-dom", () => {
    // Given: 사전 조건으로 window.location.href를 설정
    beforeEach(() => {
        window.location.href = "http://www.waglewagle.store/station/12";
    });

    it("URL에서 역 ID를 추출하여 반환한다", () => {
        // When: getStationId 함수가 호출될 때
        const stationId = getStationId();

        // Then: 예상되는 역 ID가 반환되어야 한다
        expect(stationId).toBe("12");
    });
});

비교

두번째 테스트

다음은 신고 게시물을 임시처리하는 코드다.
게시글 id를 localStorage에 저장하는 함수와
localStorage에서 신고 게시물 리스트를 불러오는 함수 2개가 있다.

export const getReports = () => {
  return [...JSON.parse(localStorage.getItem("reportList")!)];
};

export const saveReport = (postId: number) => {
  const reportList = getReports();
  reportList.push(postId);
  localStorage.setItem("reportList", JSON.stringify(reportList));
};

두가지 함수를 테스트하기 위해 describe로 묶어서 테스트 코드를 짜봤다.

import { describe, it, expect, beforeEach } from "vitest";
import { getReports, saveReport } from "./reportFn";

describe("localStorage를 사용한 report 관리", () => {
    beforeEach(() => {
        // Given: 각 테스트 실행 전에 localStorage 초기화
        window.localStorage.clear();
    });

    it("saveReport 함수는 postId를 localStorage에 저장한다", () => {
        // Given: 초기 상태에서 reportList 검증
        expect(getReports()).toEqual([]);

        // When: postId 저장
        saveReport(1);
        saveReport(2);

        // Then: 저장된 postId 배열 반환 검증
        expect(getReports()).toEqual([1, 2]);
    });

    it("getReports 함수는 저장된 postId 배열을 반환한다", () => {
        // Given: 여러 postId 저장
        saveReport(1);
        saveReport(2);
        saveReport(3);

        // When: getReports 함수 호출
        const reports = getReports();

        // Then: 저장된 postId 배열 반환 검증
        expect(reports).toEqual([1, 2, 3]);
    });
});

🥲 두번째 난관

reportListlocalStorage에 없을 수도 있어서 타입에러가 났다.
사실 ts로 마이그레이션 할 때도 문제가 있었는데 이걸 안고치고 타입 단언하는 바람에 이제서야 문제가 발견된거다.
이래서 테스트 코드를 짜는구나라는 생각이 든 순간이었다.

😁 리펙토링

예외를 처리하는 코드를 추가해서 함수를 수정했다.

export const getReports = () => {
    try {
        const data = localStorage.getItem("reportList");
        if (!data) return [];

        const reportList = JSON.parse(data);
        if (!Array.isArray(reportList)) return [];

        return reportList;
    } catch (error) {
        console.error("Parsing error in getReports:", error);
        return [];
    }
};

가독성이 조금 떨어지는 코드가 된 것 같은데 이건 추후에 리펙토링 해보겠다.
유효성을 검증하는 순수함수를 사용하면 가독성을 높일 수 있을 것 같다.

결과


회고

아직 테스트에 대해 알아갈게 많고 문법도 잘 알지 못하지만 하나씩 해봐야겠다.
참고로 문법은 공식문서 API reference에 잘 나와 있다.

참고

https://vitest.dev/

profile
성장하는 웹 프론트엔드 개발자 입니다.

0개의 댓글