
이 게시글은 현업에서 사용하는 React 고급 웹 개발 기법 배우기 (Feat. TodoList) 시리즈중 하나입니다
- Vite 시작하기, TailwindCss로 TodoList UI 구성하기
- React Hook 활용하여 TodoList 기능 개발하기, github page로 배포하기
- MSW로 API Mocking하기, React Query 사용하기
- React Testing Library & Vitest로 테스트 하기, CI 적용하기
소프트웨어 공학에서 테스트란, 소프트웨어의 품질을 보장하기 위해서 소프트웨어가 요구사항에 맞게 동작하는지, 결함이 없는지, 사용자가 기대하는 결과를 제공하는지 검증하는 과정입니다.
테스트는 왜 하는가?
테스트는 코드의 안정성을 높이고, 장기적으로 생산성을 향상시키기 위해 매우 중요한 작업입니다. 특히, 복잡한 애플리케이션의 경우, 테스트가 없으면 새로운 기능을 추가하거나 기존 기능을 수정할 때 예기치 못한 버그가 발생할 가능성이 높습니다. 테스트를 통해 이러한 리스크를 줄이고, 코드의 신뢰도를 높일 수 있습니다.
결함(Defect or Fault or Bug)은 개발자의 실수(Error)로 인해 발생할 수 있습니다. 즉 실수(Error)는 항상 있을 수 있고 이 중 일부는 결함(Bug)을 만들어 내며 이러한 결함은 사용자가 소프트웨어를 정상적으로 이용하지 못하게 하는 장애(Failure)로 이어질 수 있습니다.
예를 들어 C언어에서 int를 0으로 나누면 안되는데, 이를 처리해주지 않은 경우 Error라 할 수 있습니다. 운이 좋게 0으로 나누는 상황이 발생하지 않다가 예상치 못한 사용자의 input으로 인해 0으로 나누게 되면 결함을 일으키고 이를 적절히 처리하지 못하면 소프트웨어 작동이 중지될 수 있으며 이는 장애로 이어지게 됩니다.
테스트는 이러한 경우에서 결함을 예방하기 위해 있습니다. 위와 같은 상황에 대해서 임의로 input을 0으로 설정하여 로직을 돌리는 테스트를 해볼 수 있습니다. 만약 개발자가 실수로 0으로 나누는 것에 대한 처리를 해주지 않았다면 테스트 과정에서 결함이 발생할 것이고 개발자는 장애가 발생하기 전에 결함을 발견하고 수정할 수 있죠.
이러한 경우 뿐 만 아니라 정상적인 값을 입력했을때 잘 처리하는지도 테스트해볼 수 있겠죠. 이러한 테스트들이 잘 마련되어 있으면 신규 기능을 추가하거나 기존 코드를 리펙토링 할 때도 믿고 편하게 할 수 있습니다. 이것을 코드의 신뢰도가 높다고 할 수 있겠죠. 이러한 테스트가 코드를 수정할때마다 알아서 실행되고 문제가 있으면 알려준다면(자동화) 생산성이 향상될 수 있죠.
테스트 종류
테스트의 종류는 테스트 목적, 대상, 범위를 기준으로 나뉠 수 있으며 방법론 또한 너무 너무 많습니다. 오늘은 소프트웨어 기능을 테스트하는 목적으로 코드를 대상으로 가장 작은 범위인 함수를 대상으로 하는 유닛 테스트에 대해 알아보겠습니다.
유닛 테스트
유닛 테스트는 소프트웨어 개발 과정에서 가장 작은 단위(보통 함수나 메서드 등)를 개별적으로 테스트하는 과정입니다. 유닛 테스트의 주된 목적은 개별 코드 단위가 의도한 대로 동작하는지 확인하여, 결함을 조기에 발견하고 수정하는 것입니다.
유닛 테스트는 보통 자동화되어 있습니다. 이를 통해 코드베이스가 변경될 때마다 신속하게 테스트를 재실행할 수 있습니다. 이런 자동화 툴으로 Jest나 최근에는 Vitest가 사용되고 있습니다. 특히 Vitest 2버전이 출시되어 굉장히 빠른 속도와 강력한 기능을 자랑하기에 이번에 Vitest 사용하여 테스트 환경을 만들고 테스트를 작성해보겠습니다.
테스트 환경 구축하기
1. vitest를 Dev 의존성으로 설치해줍니다.
pnpm add -D vitest
2. 테스트 환경에서 작동하는 mock server 구성
- msw는 기본적으로 브라우저 환경에서 동작하도록 만들어져있기 때문에 브라우저 api들을 많이 사용합니다. 그러나 jest, vitest등 테스트 환경으로 node 위에서 돌기 때문에 msw/node를 사용해야 합니다.
import { setupServer } from "msw/node";
import { todoHandlers } from "./todo/handlers";
export const server = setupServer(...todoHandlers({ delayAmount: 0 }));
3. setupTests.ts를 작성해줍니다.
- 테스트 환경에서 가장 먼저 실행될 코드로, 미리 설정될 것이 있으면 여기서 해주면 됩니다.
- 이전에 만들어둔 mock api를 테스트 환경에서 사용하도록 처리해줄겁니다.
- beforeAll() 내부 함수는 하위 테스트 이전에 한번 실행됩니다.
- afterEach() 내부 함수는 각 하위 테스트 실행 이후에 실행됩니다.
- afterAll() 내부 함수는 하위 테스트가 모두 끝난 후 실행됩니다.
- 이외에도 있는데 공식 문서를 참고해주세요
- https://vitest.dev/api/#beforeall
import "@testing-library/jest-dom/vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "mocks/server";
// jsdom에 scrollIntoView가 없어서 예외 처리
window.HTMLElement.prototype.scrollIntoView = function () {};
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
4. vite.config.ts를 작성해줍니다.
- 기존 defineConfig에는 test라는 타입이 없기에 공식문서의 가이드에 따라 Triple-Slash Directives를 통해 vitest의 타입을 참조합니다.
- https://vitest.dev/config/file.html
- https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html#-reference-types-
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
base: mode === "production" ? "/vite-todo-list/" : "/",
plugins: [react(), tsconfigPaths()],
server: {
host: "localhost",
port: 3000,
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/setupTests.ts"],
include: ["**/?(*.)test.ts?(x)"],
},
}));
5. script 작성
- pnpm test를 하면 테스트가 한번 실행됩니다.
- pnpm test:watch를 하면 코드가 변경될때마다 테스트가 실행됩니다.
"scripts": {
"test": "vitest --watch=false",
"test:watch": "vitest --watch"
},
- vitest의 CLI 옵션도 굉장히 많으니 공식 문서를 참고해주세요. https://vitest.dev/config/
6. 테스트 유틸 작성하기
- react-query를 사용하면 QueryClientProvider로 감싸줘야하기 때문에 유틸을 만들어줬습니다. wrapper가 필요한 상황에서 만들어주면 되고 각 테스트 파일에서 작성해줘도 무방합니다.
/* eslint-disable react-refresh/only-export-components */
/* eslint-disable import/export */
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, renderHook, type RenderOptions } from "@testing-library/react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
export const wrapper = ({ children }: { children: React.ReactNode }) => {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) => render(ui, { wrapper, ...options });
const customRenderHook: typeof renderHook = (render, options) => renderHook(render, { wrapper, ...options });
export * from "@testing-library/react";
export { customRender as render, customRenderHook as renderHook };
테스트 작성하기
테스트 작성 원칙
Given-When-Then 패턴
https://martinfowler.com/bliki/GivenWhenThen.html
Given-When-Then 패턴은 테스트를 표현하는 스타일 입니다. 이 패턴은 테스트 조건(Given), 실행하는 동작(When), 그리고 예상되는 결과(Then)로 구성됩니다. 이 구조를 통해 테스트의 가독성을 높이고, 논리적인 흐름을 유지할 수 있습니다.
주로 When에 테스트할 함수를 실행하고 then에서 assert를 발생시킵니다.
비즈니스 로직 위주로 테스트를 작성하자
테스트의 대상은 함수이며 주로 소프트웨어에서 핵심이 되는 로직을 검증하는 것이 좋습니다. 예를 들어 돈 계산을 잘 하는지, 데이터 변환을 잘 하는지, 조건부로 분기 처리가 잘 되는지, 복잡한 데이터를 다룰때 결과가 올바른지 등...
테스트를 작성하는 데에도 꽤 많은 리소스가 필요합니다. 따라서 우리는 가성비 있는 테스트를 만들기 위해 노력해야 합니다. 마치 알고리즘 문제를 풀때 적절한 테스트 케이스를 조금만 작성해도 코드를 검증할 수 있듯이 테스트를 작성하면 좋습니다.
이러한 측면에서 저는 UI 테스트는 선호하지 않습니다.
UI는 너무 자세히 테스트 하지 않는다.
UI는 변경될 가능성이 높기 때문에 간단히 렌더링 여부만 테스트하거나 정말 중요한 요소만 테스트하는 것이 효율적입니다. 자주 변경되는 코드에 대해 세부적으로 테스트를 작성하는 것은 코드 변경을 어렵게 만들어 오히려 생산성을 떨어뜨릴 수 있습니다.
예를들어 UI 요소더라도 비즈니스적으로 중요한 부분은 당연히 테스트를 작성해주면 좋습니다.
예를들어 폼 입력이 올바르게 검증 되는지, 광고가 잘 노출되는지 등.
useTodoList 테스트 작성하기
이제 실제로 테스트를 작성해줄건데요, 이론을 다 알아도 실제 작성하는게 좀 귀찮기도 하고 뭘 테스트 해야하나 고민이 될수도 있습니다.
그럴 때는 함수 하나하나 정상 동작시에 어떻게 되어야 하는지 테스트 작성해주고, 예외 케이스도 하나씩 넣어주면서 작성하면서 시작해보면 좋을 것 같은데요. 투두리스트는 너무 간단해서 사실 그렇게 어렵지는 않을 것 같습니다. 저는 다음과 같이 작성해봤습니다.
describe("todo-list > hook", () => {
it("투두를 추가할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
const newContent = "탕후루 먹기";
// When
act(() => {
result.current.action.addTodo({ content: newContent });
});
// Then
await waitFor(() => {
const { todos } = result.current.state;
expect(todos![todos!.length - 1].content).toBe(newContent);
});
});
});
vitest가 테스트 파일을 찾아서 하나씩 실행을 할텐데요. describe는 각 테스트 스위트를 정의하는데 사용됩니다. 여러 테스트를 묶을때 사용되고 중첩해서 사용할 수 이습니다. it()은 하나의 테스트 케이스를 정의하는데 사용됩니다.
hook은 리액트 내부에서만 호출 가능하기 때문에 테스트 툴인 renderHook을 사용해서 호출해줍니다.
테스트 대상인 addTodo를 act 내부에서 호출해주는것이 좋습니다. act는 상태 변경과 상태 변경으로 인한 부수 효과들을 모두 하나로 묶어서 처리할 수 있도록 도와줍니다.
waitFor()은 상태가 비동기적으로 변경될 때 이를 기다린 후 테스트 결과를 확인할 수 있도록 합니다. await을 붙여야 결과를 얻을 수 있습니다.
이런식으로 나머지 테스트를 한번 작성해보세요. 아래는 제가 작성한 테스트 원본입니다.
import { describe, it, expect } from "vitest";
import { act, renderHook, waitFor } from "@libs/util/test-utils";
import { todos as mockTodos } from "mocks/todo/data";
import { TAB_STATE } from "../consts";
import useTodoList from ".";
describe("todo-list > hook", () => {
it("투두를 추가할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
const content = "탕후루 먹기";
// When
act(() => {
result.current.action.addTodo({ content });
});
// Then
await waitFor(() => {
const { todos } = result.current.state;
expect(todos![todos!.length - 1].content).toBe("탕후루 먹기");
});
});
it("투두를 삭제할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
const id = mockTodos[0].id;
// When
act(() => {
result.current.action.deleteTodo({ id });
});
// Then
await waitFor(() => {
const { todos } = result.current.state;
expect(todos!.find((todo) => todo.id === id)).toBeUndefined();
});
});
it("투두를 수정할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
const id = mockTodos[0].id;
const content = "탕후루 먹기";
// When
act(() => {
result.current.action.editTodo({ id, content });
});
// Then
await waitFor(() => {
const { todos } = result.current.state;
expect(todos!.find((todo) => todo.id === id)?.content).toBe("탕후루 먹기");
});
});
it("투두를 토글할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
const id = mockTodos[0].id;
const completed = mockTodos[0].completed;
// When
act(() => {
result.current.action.toggleTodo({ id });
});
// Then
await waitFor(() => {
const { todos } = result.current.state;
expect(todos!.find((todo) => todo.id === id)?.completed).toBe(!completed);
});
});
it("모든 투두를 토글할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
const completed = mockTodos.every((todo) => todo.completed);
// When
act(() => {
result.current.action.toggleTodoAll();
});
// Then
await waitFor(() => {
const { todos } = result.current.state;
expect(todos!.every((todo) => todo.completed)).toBe(!completed);
});
});
it("완료된 투두를 삭제할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
// When
act(() => {
result.current.action.deleteCompletedTodo();
});
// Then
await waitFor(() => {
const { todos } = result.current.state;
expect(todos!.filter((todo) => todo.completed)).toHaveLength(0);
});
});
it("현재 탭을 변경할 수 있다.", async () => {
// Given
const { result } = renderHook(() => useTodoList());
// When
act(() => {
result.current.action.setCurrentTab(TAB_STATE.COMPLETED);
});
// Then
await waitFor(() => {
const { currentTab } = result.current.state;
expect(currentTab).toBe(TAB_STATE.COMPLETED);
});
});
});
View 테스트 작성하기
투두리스트이기에 딱히 작성할만한 테스트가 없습니다. 여기서는 렌더링이 잘 되는지만 테스트하고 넘어가도록 하겠습니다. 다시 말씀드리지만 비즈니스적으로 중요한 화면일 경우 테스트가 필요할 수 있습니다.
또한 view는 다양한 테스트 툴과 기법이 있으니 React Testing Library 공식 문서를 참고 부탁드립니다.
describe("todo-list > view ", () => {
it("정상적으로 렌더링되어야 함", async () => {
// Given
// When
render(<TodoList />);
// Then
await waitFor(() => {
expect(screen.getByText(mockTodos[0].content)).toBeInTheDocument();
});
});
});
CI 구성하기
github action으로 간단하게 commit에 대해 테스트 자동화를 하도록 하겠습니다. 이러한 구성을 하면 PR을 올리면 테스트가 통과했는지 여부로 머지를 막을 수 있습니다. 또한 결함을 미리 파악하고 대처할 수 있는데요, 주로 기존 코드를 수정했을 때 테스트가 깨지는 경우가 많습니다. 기존 기능을 리펙토링 하거나 기능을 수정할때 나도 모르게 다른 기능에 결함을 일으키는 것을 사전에 파악하고 고칠 수 있죠.
github를 쓰면 구성은 간단합니다. 특정 폴더에 워크플로 파일을 넣어주면 됩니다.
다음 경로에 다음 파일을 넣어주세요.
경로: 프로젝트 루트/.github/workflows/vitest.yml
워크플로우의 실행 과정이나 세팅법 발동 조건 등은 다양하게 커스텀할 수 있는데요. 간단히 주석으로 남기겠습니다.
파일 이름이나 내용의 name은 마음대로 하셔도 됩니다.
name: Vitest Unit Test
on: push
jobs:
build:
# 우분투 OS에서 실행
runs-on: ubuntu-latest
strategy:
matrix:
# node 18 환경에서 실행할 것이라고 변수를 설정함.
# 아래 actions/setup-node@v3에서 사용됨.
node-version: [18.x]
steps:
# workflow가 실행될 때의 소스 코드를 가져오는 작업.
# 다른 개발자가 만들어 둔 미리 정의된 action입니다. https://github.com/actions/checkout
# git 초기화, fetch, checkout 등을 수행하여 해당 브랜치의 소스 코드를 가져옵니다.
- uses: actions/checkout@v3
# name을 붙여 해당 작업의 이름을 정의할 수 있습니다.
# 실행 환경에서 Node 환경 설정을 돕는 action입니다.
# 노드를 다운받고 노드와 의존성을 캐싱해줍니다.
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
# pnpm 설치
- name: Install pnpm
run: npm install -g pnpm
# 의존성 설치
- name: npm ci
run: pnpm install --frozen-lockfile
# 테스트 script 실행
- name: vitest
run: pnpm test
다음과 같이 커밋 옆에 체크 표시 또는 X 표시로 CI 성공/실패 표시가 뜨게 됩니다.
다음은 일부러 테스트를 실패시킨 모습입니다. PR에서 X 표시가 표시욉니다.
이렇게 해서 "현업에서 사용하는 react 고급 웹 개발 기법 배우기 (feat. todolist)" 시리즈를 마치겠습니다.
지금까지 읽어주신 분들께 감사드립니다!
'FrontEnd' 카테고리의 다른 글
MSW로 API Mocking하기, React Query 사용하기 (1) | 2024.03.17 |
---|---|
React Hook 활용하여 TodoList 기능 개발하기, github pages로 배포하기 (2) | 2024.01.25 |
Vite로 시작하기, TailwindCss로 TodoList UI 구성하기 (0) | 2024.01.21 |
현업에서 사용하는 React 고급 웹 개발 기법 배우기 (Feat. TodoList) (2) | 2024.01.20 |