🔨

Webフロントエンド開発(React)でテストを書きながらリファクタリングする方法

2024/10/27に公開

テスト書いてますか?

はじめに

アプリケーションの開発をしていると、なるべく早い時期からテストを書いたほうが良いとわかっていても、まずは機能を作ることに注力してしまいがちです。ついテストの作成を実装の後にまわすことになりますが、メインブランチにマージする段階では機能の実装とともにテストも含めたいものです。

また、テストコードを書くことはアプリケーションコードをどのように書けばテストしやすいかを考えることにも繋がり、結果的にアプリケーションコードに責務分割された構造化をもたらすことになります。アプリケーションコードの設計のためにもテストを書くことを活用したいものです。

本記事ではベタ書きで作成済みのアプリケーションコードにテストコードを追加し、段階的にアプリケーションコードとテストコードのリファクタリングを進めていくプロセスを紹介します。

1. ベタ書きのアプリケーションの概要

最初にテストを書く対象となるベタ書きしたアプリケーションについて説明します。

仕様

ファイルとディレクトリの構成

ファイルとディレクトリの構成は次のようになっています。

src
├── app
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── components
│   ├── pages
│   │   └── TodosPage
│   │       ├── TodosPage.tsx
│   │       └── index.ts
│   └── ui
├── gen
└── lib

Next.jsでApp Routerを利用した構造となっていますが、src/appディレクトリはルーティングに全振りしており、ページコンポーネントの実体はsrc/components/pagesの配下にあります。

また、本記事では詳細を割愛しますが、WEB APIの仕様はOpenAPIのスキーマで定義しており、openapi-generator-cliを利用してWEB APIクライアントのコードをsrc/gen配下に生成しています。

(参考) OpenAPIのスキーマ定義

スキーマ定義は次のとおりです。

schema/openapi.yaml
openapi: '3.0.3'
info:
  title: 'Todo API'
  description: API for managing Todo tasks.
  version: '1.0.0'
servers:
  - url: http://{host}:{port}
    description: for development
    variables:
      host:
        default: localhost
      port:
        default: '3000'
tags:
  - name: Todos
    description: Managing todos.
paths:
  /api/todos:
    get:
      tags:
        - Todos
      operationId: getTodos
      summary: Get all Todo tasks
      responses:
        "200":
          description: A list of Todo tasks
          content:
            application/json:
              schema:
                type: object
                required:
                  - todos
                properties:
                  todos:
                    type: array
                    items:
                      $ref: "#/components/schemas/Todo"

components:
  schemas:
    Todo:
      type: object
      properties:
        id:
          type: integer
          example: 1
        title:
          type: string
          example: Check mail.
        completed:
          type: boolean
          example: false
        createdAt:
          type: string
          format: date-time
          example: "2023-01-01T00:00:00.000Z"
        updatedAt:
          type: string
          format: date-time
          example: "2023-01-01T00:00:00.000Z"
      required:
        - id
        - title
        - completed
        - createdAt
        - updatedAt

このスキーマを利用して次のコマンドでコード生成しています。

openapi-generator-cli generate -g typescript-fetch -i ./schema/openapi.yaml -o ./src/gen

2. 最初のアプリケーションコード

ドキュメントルート http://localhost:3000/ となるsrc/app/page.tsxの中身は次のようになっています。
インポートしてきたTodosPageコンポーネントをそのままエクスポートして使っています。

src/app/page.tsx
import { TodosPage } from "@/components/pages/TodosPage";

export default TodosPage;

TodosPage ディレクトリ直下のindex.tsファイルではTodosPage.tsxファイルのTodosPageコンポーネントをエクスポートしています。

src/components/pages/TodosPage/index.ts
export { TodosPage } from "./TodosPage"

具体的なTodosPageコンポーネントの実装は次のとおりです。

src/components/pages/TodosPage/TodosPage.tsx
"use client";

import { format } from "date-fns";
import { CheckCircleIcon, CircleIcon } from "lucide-react";
import useSWR from "swr";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { TodosApi } from "@/gen";

export function TodosPage() {
  const { data } = useSWR("/api/todos", async () => {
    const client = new TodosApi();
    const res = await client.getTodos();
    return res.todos;
  });

  return (
    <main className="p-4">
      <Card>
        <CardHeader>
          <CardTitle>Todos</CardTitle>
        </CardHeader>
        <CardContent>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead className="w-6" />
                <TableHead>Title</TableHead>
                <TableHead className="w-8">Updated</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {data && data.length > 0 ? (
                data.map((todo) => (
                  <TableRow key={todo.id} data-testid="todosListRow">
                    <TableCell>
                      {todo.completed ? (
                        <CheckCircleIcon data-testid="completed" />
                      ) : (
                        <CircleIcon data-testid="uncompleted" />
                      )}
                    </TableCell>
                    <TableCell data-testid="title">{todo.title}</TableCell>
                    <TableCell data-testid="updated">
                      {format(todo.updatedAt, "yyyy/MM/dd")}
                    </TableCell>
                  </TableRow>
                ))
              ) : (
                <TableRow data-testid="empty">
                  <TableCell colSpan={3}>No data.</TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </CardContent>
      </Card>
    </main>
  );
}

このコードは実際に動作し、次のように画面表示されます。

画面表示

このコードに対してテストコードを作成していきます。

3. 最初のテスト

テスト環境のセットアップ

まずはテスト環境を整備します。今回使う主なテストフレームワーク及びライブラリは次のとおりです。

必要なライブラリをインストールします。

pnpm add -D vitest happy-dom @testing-library/dom @testing-library/jest-dom @testing-library/react @vitejs/plugin-react @vitest/coverage-v8

プロジェクトルートに vitest.config.mtsファイルを作成し、vitest の設定を行います。React 用のプラグインを有効にし、コンポーネントの描画を扱うために happy-dom を使用します。各設定の詳細はvitestのドキュメントを参照してください。

vitest.config.mts
import path from 'path'

import react from '@vitejs/plugin-react'
import { defineConfig, configDefaults } from 'vitest/config'

export default defineConfig({
  plugins: [react()],
  test: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
    globals: true,
    environment: 'happy-dom',
    setupFiles: ["./vitest.setup.ts"],
    include: ["src/**/*.test.ts?(x)"],
    clearMocks: true,
    coverage: {
      exclude: [
        ...configDefaults.coverage.exclude!,
        "src/gen/**/*",
        "src/components/ui/**/*",
        "*.config.*"
      ]
    }
  },
})

testing-library を追加することで使える便利なAPIを常に使用するために、vitest.setup.tsファイルを作成して共通処理のインポートを行っておきます。

vitest.setup.ts
import "@testing-library/jest-dom/vitest";

また、エディタがテストライブラリを認識するように、tsconfig.json"types": ["vitest/globals", "@testing-library/dom", "@testing-library/jest-dom"]を追記します。

tsconfig.json
{
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": [
      "vitest/globals",
      "@testing-library/dom",
      "@testing-library/jest-dom"
    ]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

テストを実行するために package.json の scripts に test コマンドを追記します。

package.json
{
  "name": "zen-todo",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "prebuild": "pnpm gen:api",
    "build": "next build",
    "gen:api": "rimraf ./src/gen && openapi-generator-cli generate -g typescript-fetch -i ./schema/openapi.yaml -o ./src/gen",
    "start": "next start",
    "lint": "next lint",
    "test": "vitest run",
    "test:coverage": "vitest run --coverage"
  },

テストを実行してみて、環境の動作を確認します。

pnpm test

> zen-todo@0.1.0 test /storage/workspace/zen-todo
> vitest run


 RUN  v2.1.3 /storage/workspace/zen-todo

include: src/**/*.test.ts?(x)
exclude:  **/node_modules/**, **/dist/**, **/cypress/**, **/.{idea,git,cache,output,temp}/**, **/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*

No test files found, exiting with code 1
 ELIFECYCLE  Test failed. See above for more details.

テストがまだ存在しないので失敗で終わりますが、準備は整いました。

最初のテストコード

TodosPageコンポーネントに対してのテストを作成します。

Reactコンポーネントのテストの基本

@testing-library/react を利用してテストします。

Reactコンポーネントのテストを書く場合、基本的には render を使用してコンポーネントを描画し、アサーションを書くことになります。

シンプルな例
const r = render(<SomeComponent />);
expect(r).toBeInTheDocument()

WEB APIからデータ取得するコンポーネントの対応

TodosPageコンポーネントではマウント時にWeb APIでTodoリストデータを取得しています。

テストするにはこのテストしにくい要素に対処をする必要があります。
今回のケースでは次の3つの案が考えられます。

  1. SWRConfigでfallbackを使い、useSWRが返す値を事前に用意する。
  2. TodosApiのgetTodosメソッドにモックを適用し、返す値を事前に用意する。
  3. fetchをモックして返すHTTPレスポンスを事前に用意する。

案1は準備が楽ですが、useSWRのfetcherの中身の実装を検証できません。
今回のケースではTodosApiはOpenAPIスキーマから生成したコードなので、getTodosメソッドの実装までを検証する必要がないとみなして案2を採用します。

具体的には次のように vitest の spyOnを利用します。spyOnの詳細はvitestのドキュメントを参照してください。

spyOn
vi.spyOn(TodosApi.prototype, "getTodos").mockResolvedValueOnce({
  todos: [
    {
      id: 1,
      title: "todo1",
      completed: false,
      createdAt: new Date("2024-01-01"),
      updatedAt: new Date("2024-01-02"),
    }
  ],
});

また、マウント時にデータ取得し、取得後に描画するのでTodosPageコンポーネントのライフサイクルは次のようになります。

  1. 初期状態(マウント時)
  2. データ取得完了

そのため、render メソッドで描画直後は必ずTodoデータが存在しないことになり、取得が完了するまでアサーションの実行を待ってあげる必要があります。

この待機には @testing-library/react の waitFor を使います。

具体的には次のようにして期待する実行結果が得られることを待機するようにします。

waitFor
await waitFor(() => expect(r.getAllByTestId("todosListRow")).toHaveLength(1));

SWRを使用している場合の対処法

今回のTodosPageコンポーネントでは内部でSWRを利用してWEB APIからのデータ取得をキャッシュしています。
そのため、テストケースごとにキャッシュされたデータが使われないように対応する必要があります。

具体的には、render の wrapper として キャッシュさせないプロバイダを指定した SWRConfigを適用します。詳細はSWRのドキュメントを参照してください。

SWR対策
render(<TodosPage />, {
  wrapper: ({ children }) => (
    <SWRConfig
      value={{
        provider: () => new Map(),
      }}
    >
      {children}
    </SWRConfig>
  );

TodosPageのテストコード

ここまでの対応を考慮したTodosPageのテストコードは次のようになります。

render や spyOn など毎回行う対応はテスト内の関数にまとめて整理してあります。
データの件数や状態によっての表示の切り分けもテストケースに盛り込んでいます。

テストで使用しているアサーションについては vitestのドキュメント@testing-library/jest-dom のドキュメントを参照してください。

src/components/pages/TodosPage/TodosPage.test.tsx
import { render, waitFor } from "@testing-library/react";
import { format } from "date-fns";
import { SWRConfig } from "swr";

import { Todo, TodosApi } from "@/gen";

import { TodosPage } from ".";

describe("TodosPage", () => {
  const base: Todo = {
    id: 1,
    title: "todo1",
    completed: false,
    createdAt: new Date("2024-01-01"),
    updatedAt: new Date("2024-01-02"),
  };

  const run = () =>
    render(<TodosPage />, {
      wrapper: ({ children }) => (
        // https://swr.vercel.app/ja/docs/advanced/cache#reset-cache-between-test-cases
        <SWRConfig
          value={{
            provider: () => new Map(),
          }}
        >
          {children}
        </SWRConfig>
      ),
    });

  const spyOnGetTodos = (todos: Todo[]) =>
    vi.spyOn(TodosApi.prototype, "getTodos").mockResolvedValueOnce({
      todos,
    });

  describe("一覧の表示", () => {
    describe("データが存在する場合", () => {
      it("Todo一覧取得APIの結果データを一覧に表示する", async () => {
        spyOnGetTodos([
          base,
          {
            ...base,
            id: 2,
            title: "todo2",
          },
        ]);

        const r = run();
        await waitFor(() =>
          expect(
            r.getAllByTestId("todosListRow"),
            "2行描画されること",
          ).toHaveLength(2),
        );
      });
    });

    describe("データが存在しない場合", () => {
      it("データなしメッセージを表示する", async () => {
        spyOnGetTodos([]);

        const r = run();
        await waitFor(() => expect(r.getByTestId("empty")).toBeInTheDocument());
      });
    });
  });

  describe("状態列", () => {
    describe("completedがtrueの場合", () => {
      it("チェック済みアイコンを表示する", async () => {
        spyOnGetTodos([
          {
            ...base,
            completed: true,
          },
        ]);

        const r = run();
        await waitFor(() =>
          expect(r.getByTestId("completed")).toBeInTheDocument(),
        );
      });
    });

    describe("completedがfalseの場合", () => {
      it("チェックなしアイコンを表示する", async () => {
        spyOnGetTodos([
          {
            ...base,
            completed: false,
          },
        ]);

        const r = run();
        await waitFor(() =>
          expect(r.getByTestId("uncompleted")).toBeInTheDocument(),
        );
      });
    });
  });

  describe("タイトル列", () => {
    it("Todoのタイトルを表示する", async () => {
      spyOnGetTodos([base]);

      const r = run();
      await waitFor(() =>
        expect(r.getByTestId("title")).toHaveTextContent(base.title),
      );
    });
  });

  describe("更新日列", () => {
    it("Todoの更新日を表示する", async () => {
      spyOnGetTodos([base]);

      const r = run();
      await waitFor(() =>
        expect(r.getByTestId("updated")).toHaveTextContent(
          format(base.updatedAt, "yyyy/MM/dd"),
        ),
      );
    });
  });
});

テストを実行してみます。コマンドは次を実行します。

pnpm test

実行結果は次のようになります。

pnpm test

> zen-todo@0.1.0 test /storage/workspace/zen-todo
> vitest run


 RUN  v2.1.3 /storage/workspace/zen-todo

 ✓ src/components/pages/TodosPage/TodosPage.test.tsx (6)
   ✓ TodosPage (6)
     ✓ 一覧の表示 (2)
       ✓ データが存在する場合 (1)
         ✓ Todo一覧取得APIの結果データを一覧に表示する
       ✓ データが存在しない場合 (1)
         ✓ データなしメッセージを表示する
     ✓ 状態列 (2)
       ✓ completedがtrueの場合 (1)
         ✓ チェック済みアイコンを表示する
       ✓ completedがfalseの場合 (1)
         ✓ チェックなしアイコンを表示する
     ✓ タイトル列 (1)
       ✓ Todoのタイトルを表示する
     ✓ 更新日列 (1)
       ✓ Todoの更新日を表示する

 Test Files  1 passed (1)
      Tests  6 passed (6)
   Start at  17:41:21
   Duration  774ms (transform 73ms, setup 59ms, collect 266ms, tests 68ms, environment 137ms, prepare 85ms)

これでTodosPageコンポーネントの動作を検証するテストが準備できました。ここからアプリケーションコードをリファクタリングしていきます。

4. リファクタリング

先程のTodosPageのテストコードでは主に次のような構成になっていました。

  • WEB API部分をモック
  • Todoテーブルの表示件数に応じたテスト
  • Todoテーブル行の列ごとの表示内容のテスト

Todoテーブルをコンポーネントとして分離すれば、WEB APIの実行を待つ必要なくコンポーネントにプロパティを渡すだけでテストできるようになりそうです。ここからリファクタリングしていきます。

Todoテーブルをコンポーネントに分離

はじめにTodosPageの中のTodoテーブルをTodosListコンポーネントとして切り出します。

TodosListコンポーネントはTodosPageの内部コンポーネントとして使用するため、TodosPageディレクトリ内にサブディレクトリを作成して配置します。

ディレクトリを作成します。

mkdir -p src/components/pages/TodosPage/components/TodosList

作成したディレクトリ内に TodosList.tsx を作成し、TodosPageからテーブル部分を移植します。

src/components/pages/TodosPage/components/TodosList/TodosList.tsx
import { format } from "date-fns";
import { CheckCircleIcon, CircleIcon } from "lucide-react";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Todo } from "@/gen";

type Props = {
  todos: Todo[];
};

export function TodosList({ todos }: Props) {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead className="w-6" />
          <TableHead>Title</TableHead>
          <TableHead className="w-8">Updated</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {todos.length > 0 ? (
          todos.map((todo) => (
            <TableRow key={todo.id} data-testid="todosListRow">
              <TableCell>
                {todo.completed ? (
                  <CheckCircleIcon data-testid="completed" />
                ) : (
                  <CircleIcon data-testid="uncompleted" />
                )}
              </TableCell>
              <TableCell data-testid="title">{todo.title}</TableCell>
              <TableCell data-testid="updated">
                {format(todo.updatedAt, "yyyy/MM/dd")}
              </TableCell>
            </TableRow>
          ))
        ) : (
          <TableRow data-testid="empty">
            <TableCell colSpan={3}>No data.</TableCell>
          </TableRow>
        )}
      </TableBody>
    </Table>
  );
}

index.tsを作ってエクスポートします。

src/components/pages/TodosPage/components/TodosList/index.ts
export { TodosList } from "./TodosList"

TodosPageコンポーネントではTodosListを使う形にコードを書き換えます。

src/components/pages/TodosPage/TodosPage.tsx
"use client";

import useSWR from "swr";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TodosApi } from "@/gen";

import { TodosList } from "./components/TodosList";

export function TodosPage() {
  const { data } = useSWR("/api/todos", async () => {
    const client = new TodosApi();
    const res = await client.getTodos();
    return res.todos;
  });

  return (
    <main className="p-4">
      <Card>
        <CardHeader>
          <CardTitle>Todos</CardTitle>
        </CardHeader>
        <CardContent>
          <TodosList todos={data || []} />
        </CardContent>
      </Card>
    </main>
  );
}

TodosPageはコードがスッキリしてきました。
テストを実行し、TodosPageとしての動作が変わらずに壊れていないことを確認します。

Todoテーブルは行とその列の描画責務まで持っていますが、行を更にコンポーネントとして切り出せば責務を減らせそうです。

Todoテーブル行をコンポーネントに分離

TodosListコンポーネントの中で行を更にコンポーネントに切り出します。

こちらもTodosListコンポーネント内でのみ使うコンポーネントになるので、TodosListディレクトリ内に更にコンポーネント用のディレクトリを切って管理します。

ディレクトリを作成します。

mkdir -p src/components/pages/TodosPage/components/TodosList/components/TodosListRow

TodosListRow.tsxファイルを作成します。中身はTodosListコンポーネントから移植してきます。

src/components/pages/TodosPage/components/TodosList/components/TodosListRow/TodosListRow.tsx
import { format } from "date-fns";
import { CheckCircleIcon, CircleIcon } from "lucide-react";

import { TableCell, TableRow } from "@/components/ui/table";
import { Todo } from "@/gen";

type Props = {
  todo: Todo;
};

export function TodosListRow({ todo }: Props) {
  return (
    <TableRow data-testid="todosListRow">
      <TableCell>
        {todo.completed ? (
          <CheckCircleIcon data-testid="completed" />
        ) : (
          <CircleIcon data-testid="uncompleted" />
        )}
      </TableCell>
      <TableCell data-testid="title">{todo.title}</TableCell>
      <TableCell data-testid="updated">
        {format(todo.updatedAt, "yyyy/MM/dd")}
      </TableCell>
    </TableRow>
  );
}

こちらもindex.tsでエクスポートしておきます。

src/components/pages/TodosPage/components/TodosList/components/TodosListRow/index.ts
export { TodosListRow } from "./TodosListRow"

TodosListではTodosListRowを使うように修正します。

src/components/pages/TodosPage/components/TodosList/TodosList.tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Todo } from "@/gen";

import { TodosListRow } from "./components/TodosListRow";

type Props = {
  todos: Todo[];
};

export function TodosList({ todos }: Props) {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead className="w-6" />
          <TableHead>Title</TableHead>
          <TableHead className="w-8">Updated</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {todos.length > 0 ? (
          todos.map((todo) => <TodosListRow key={todo.id} todo={todo} />)
        ) : (
          <TableRow data-testid="empty">
            <TableCell colSpan={3}>No data.</TableCell>
          </TableRow>
        )}
      </TableBody>
    </Table>
  );
}

TodosListも先程よりもスッキリしてきました。この時点でも最初に作成したテストは通ります。

コンポーネントを分離したので、テストコードもリファクタリングを行います。

5. テストコードのリファクタリング

最初に作成したテストではTodoテーブルやテーブル行の詳細までテストケースとして持っていました。

これらをそれぞれのコンポーネントのテストに移していきます。

TodosListRowのテスト作成とテストケース移動

TodosListRowのテストを作成します。テストコードはTodosPageのテストから移動してきますが、WEB APIの実行を待つ必要がなくなったので、アサーション適用部分の waitFor を外します。

src/components/pages/TodosPage/components/TodosList/components/TodosListRow/TodosListRow.test.tsx
import { render } from "@testing-library/react";
import { format } from "date-fns";

import { Todo } from "@/gen";

import { TodosListRow } from ".";

describe("TodosListRow", () => {
  const base: Todo = {
    id: 1,
    title: "todo1",
    completed: false,
    createdAt: new Date("2024-01-01"),
    updatedAt: new Date("2024-01-02"),
  };

  const run = (todo: Todo) =>
    render(<TodosListRow todo={todo} />, {
      wrapper: ({ children }) => (
        <table>
          <tbody>{children}</tbody>
        </table>
      ),
    });

  describe("状態列", () => {
    describe("completedがtrueの場合", () => {
      it("チェック済みアイコンを表示する", () => {
        const r = run({
          ...base,
          completed: true,
        });
        expect(r.getByTestId("completed")).toBeInTheDocument();
      });
    });

    describe("completedがfalseの場合", () => {
      it("チェックなしアイコンを表示する", () => {
        const r = run({
          ...base,
          completed: false,
        });
        expect(r.getByTestId("uncompleted")).toBeInTheDocument();
      });
    });
  });

  describe("タイトル列", () => {
    it("Todoのタイトルを表示する", () => {
      const r = run(base);
      expect(r.getByTestId("title")).toHaveTextContent(base.title);
    });
  });

  describe("更新日列", () => {
    it("Todoの更新日を表示する", () => {
      const r = run(base);
      expect(r.getByTestId("updated")).toHaveTextContent(
        format(base.updatedAt, "yyyy/MM/dd"),
      );
    });
  });
});

TodosListのテスト作成とテストケース移動

TodosListのテストもTodosPageのテストからテストケースを移植します。こちらも waitFor の待機は外せます。

src/components/pages/TodosPage/components/TodosList/TodosList.test.tsx
import { render } from "@testing-library/react";

import { Todo } from "@/gen";

import { TodosList } from ".";

describe("TodosList", () => {
  const base: Todo = {
    id: 1,
    title: "todo1",
    completed: false,
    createdAt: new Date("2024-01-01"),
    updatedAt: new Date("2024-01-02"),
  };

  const run = (todos: Todo[]) => render(<TodosList todos={todos} />);

  describe("データが存在する場合", () => {
    it("Todoデータを一覧に表示する", () => {
      const r = run([
        base,
        {
          ...base,
          id: 2,
          title: "todo2",
        },
      ]);
      expect(
        r.getAllByTestId("todosListRow"),
        "2行描画されること",
      ).toHaveLength(2);
    });
  });

  describe("データが存在しない場合", () => {
    it("データなしメッセージを表示する", () => {
      const r = run([]);
      expect(r.getByTestId("empty")).toBeInTheDocument();
    });
  });
});

TodosPageのテストを修正

テストケースを移動したのでTodosPageのテスト自体も移動した分を減らします。

src/components/pages/TodosPage/TodosPage.test.tsx
import { render, waitFor } from "@testing-library/react";
import { SWRConfig } from "swr";

import { Todo, TodosApi } from "@/gen";

import { TodosPage } from ".";

describe("TodosPage", () => {
  const base: Todo = {
    id: 1,
    title: "todo1",
    completed: false,
    createdAt: new Date("2024-01-01"),
    updatedAt: new Date("2024-01-02"),
  };

  const run = () =>
    render(<TodosPage />, {
      wrapper: ({ children }) => (
        // https://swr.vercel.app/ja/docs/advanced/cache#reset-cache-between-test-cases
        <SWRConfig
          value={{
            provider: () => new Map(),
          }}
        >
          {children}
        </SWRConfig>
      ),
    });

  const spyOnGetTodos = (todos: Todo[]) =>
    vi.spyOn(TodosApi.prototype, "getTodos").mockResolvedValueOnce({
      todos,
    });

  describe("一覧の表示", () => {
    it("Todo一覧取得APIの結果データを一覧に表示する", async () => {
      spyOnGetTodos([base]);

      const r = run();
      await waitFor(() =>
        expect(
          r.getAllByTestId("todosListRow"),
          "1行描画されること",
        ).toHaveLength(1),
      );
    });
  });
});

TodosPageのテストコードも最初に比べてスッキリしました。

リファクタリングを行った後のテストを実行し、テストが通ることを確認します。

pnpm test                                           

> zen-todo@0.1.0 test /storage/workspace/zen-todo
> vitest run


 RUN  v2.1.3 /storage/workspace/zen-todo

 ✓ src/components/pages/TodosPage/TodosPage.test.tsx (1)
 ✓ src/components/pages/TodosPage/components/TodosList/TodosList.test.tsx (2)
 ✓ src/components/pages/TodosPage/components/TodosList/components/TodosListRow/TodosListRow.test.tsx (4)

 Test Files  3 passed (3)
      Tests  7 passed (7)
   Start at  17:59:30
   Duration  763ms (transform 125ms, setup 181ms, collect 759ms, tests 90ms, environment 476ms, prepare 220ms)

6. アプリケーションコードの再リファクタリング

さらにアプリケーションコードをリファクタリングしてみます。

TodosPageコンポーネントではTodosApiとuseSWRを使ってデータ取得を行っていましたので、これを専用のフックとして切り出してみます。

useGetTodosフックの切り出し

WEB APIからTodo一覧を取得する処理は将来的に他のページでも使用する可能性があるので、TodosPage内のフックとはせずに共通フックとして切り出してみます。

最初に格納先のディレクトリを作成します。

mkdir -p src/hooks/useGetTodos

作成したディレクトリ内にuseGetTodos.tsファイルを作成します。内容はTodosPageから移植してきます。

src/hooks/useGetTodos/useGetTodos.ts
import useSWR from "swr";

import { TodosApi } from "@/gen";

export function useGetTodos() {
  const { data } = useSWR("/api/todos", async () => {
    const client = new TodosApi();
    const res = await client.getTodos();
    return res.todos;
  });

  return {
    todos: data || []
  }
}

これもindex.tsでエクスポートします。

src/hooks/useGetTodos/index.ts
export { useGetTodos } from "./useGetTodos"

TodosPageではuseGetTodosフックを使うように修正します。

src/components/pages/TodosPage/TodosPage.tsx
"use client";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useGetTodos } from "@/hooks/useGetTodos";

import { TodosList } from "./components/TodosList";

export function TodosPage() {
  const { todos } = useGetTodos();

  return (
    <main className="p-4">
      <Card>
        <CardHeader>
          <CardTitle>Todos</CardTitle>
        </CardHeader>
        <CardContent>
          <TodosList todos={todos} />
        </CardContent>
      </Card>
    </main>
  );
}

テストを実行し、壊れていないことを確認します。

useGetTodosフックのテストを作成

分離したuseGetTodosのテストを書いてみます。Reactのカスタムフックのテストは @testing-library/react の renderHook を使用します。
詳細は@testing-library/reactのドキュメントを参照してください。

基本的なフックのテストの書き方は次のとおりです。

基本的なフックのテスト
const r = renderHook(() => useSomeHooks());
expect(r.result.current.hoge).toBe("xxxx");

useGetTodosはSWRに依存しているので最初のテストで触れたSWRConfigのキャッシュの無効化が必要です。renderHook でも wrapper オプションで対応します。

また、WEB APIからデータ取得するのでTodosAPIのspyOnによるモック化が必要となります。
ライフサイクルに関しても考慮が必要でwaitForによる待機を行います。

それらを踏まえたテストは次のとおりです。

src/hooks/useGetTodos/useGetTodos.test.tsx
import { renderHook, waitFor } from "@testing-library/react";
import { SWRConfig } from "swr";

import { Todo, TodosApi } from "@/gen";

import { useGetTodos } from ".";

describe("useGetTodos", () => {
  const base: Todo = {
    id: 1,
    title: "todo1",
    completed: false,
    createdAt: new Date("2024-01-01"),
    updatedAt: new Date("2024-01-02"),
  };

  const run = () =>
    renderHook(() => useGetTodos(), {
      wrapper: ({ children }) => (
        <SWRConfig
          value={{
            provider: () => new Map(),
          }}
        >
          {children}
        </SWRConfig>
      ),
    });

  const spyOnGetTodos = (todos: Todo[]) =>
    vi.spyOn(TodosApi.prototype, "getTodos").mockResolvedValueOnce({
      todos,
    });

  describe("todos", () => {
    it("Todo一覧取得APIの結果データを返す", async () => {
      spyOnGetTodos([base]);

      const r = run();
      await waitFor(() => expect(r.result.current.todos).toEqual([base]));
    });
  });
});

追加したテストを含めてテスト実行した結果は次のとおりです。

pnpm test

> zen-todo@0.1.0 test /storage/workspace/zen-todo
> vitest run


 RUN  v2.1.3 /storage/workspace/zen-todo

 ✓ src/hooks/useGetTodos/useGetTodos.test.tsx (1)
 ✓ src/components/pages/TodosPage/TodosPage.test.tsx (1)
 ✓ src/components/pages/TodosPage/components/TodosList/TodosList.test.tsx (2)
 ✓ src/components/pages/TodosPage/components/TodosList/components/TodosListRow/TodosListRow.test.tsx (4)

 Test Files  4 passed (4)
      Tests  8 passed (8)
   Start at  22:19:21
   Duration  838ms (transform 118ms, setup 326ms, collect 948ms, tests 162ms, environment 721ms, prepare 371ms)

切り出したことでuseGetTodosの詳細はuseGetTodosのテストで行えるようになったので、TodosPageのテストではモック化の対象をuseGetTodosに変えることができます(今回は割愛します)。

まとめ

本記事ではベタ書きのアプリケーションコードに対してテストを作成し、リファクタリングを行いました。

注意点として今回のテストではコードのロジック上の故障は検知できますが、ブラウザ上の見た目(スタイルなど)を検知することはできません。見た目に関しては別途 Visual Regression Test を検討するべきでしょう。

Appendix

今回作成したテスト環境ではカバレッジ取得の設定も行っています。

カバレッジを計測する場合は次のコマンドで行います。

pnpm test:coverage

実行結果は次のようになります。

pnpm test:coverage

> zen-todo@0.1.0 test:coverage /storage/workspace/zen-todo
> vitest run --coverage


 RUN  v2.1.3 /storage/workspace/zen-todo
      Coverage enabled with v8

 ✓ src/hooks/useGetTodos/useGetTodos.test.tsx (1)
 ✓ src/components/pages/TodosPage/TodosPage.test.tsx (1)
 ✓ src/components/pages/TodosPage/components/TodosList/TodosList.test.tsx (2)
 ✓ src/components/pages/TodosPage/components/TodosList/components/TodosListRow/TodosListRow.test.tsx (4)

 Test Files  4 passed (4)
      Tests  8 passed (8)
   Start at  09:41:51
   Duration  1.00s (transform 131ms, setup 384ms, collect 943ms, tests 164ms, environment 681ms, prepare 260ms)

 % Coverage report from v8
------------------------------------------------------|---------|----------|---------|---------|-------------------
File                                                  | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
------------------------------------------------------|---------|----------|---------|---------|-------------------
All files                                             |   81.17 |    85.71 |   71.42 |   81.17 |                   
 app                                                  |       0 |        0 |       0 |       0 |                   
  layout.tsx                                          |       0 |        0 |       0 |       0 | 1-19              
  page.tsx                                            |       0 |        0 |       0 |       0 | 1-3               
 components/pages/TodosPage                           |     100 |      100 |     100 |     100 |                   
  TodosPage.tsx                                       |     100 |      100 |     100 |     100 |                   
  index.ts                                            |     100 |      100 |     100 |     100 |                   
 components/pages/TodosPage/components/TodosList      |     100 |      100 |     100 |     100 |                   
  TodosList.tsx                                       |     100 |      100 |     100 |     100 |                   
  index.ts                                            |     100 |      100 |     100 |     100 |                   
 ...Page/components/TodosList/components/TodosListRow |     100 |      100 |     100 |     100 |                   
  TodosListRow.tsx                                    |     100 |      100 |     100 |     100 |                   
  index.ts                                            |     100 |      100 |     100 |     100 |                   
 hooks/useGetTodos                                    |     100 |      100 |     100 |     100 |                   
  index.ts                                            |     100 |      100 |     100 |     100 |                   
  useGetTodos.ts                                      |     100 |      100 |     100 |     100 |                   
 lib                                                  |     100 |      100 |     100 |     100 |                   
  utils.ts                                            |     100 |      100 |     100 |     100 |                   
------------------------------------------------------|---------|----------|---------|---------|-------------------

実行結果のHTMLレポートがプロジェクトルートのcoverage/index.htmlに保存されるので、コードのどこが実行されているかを確認することができます。

HTMLレポート

ファイルのレポート例

分岐網羅などでテストする条件が足りているかを確認するのに参考になります。

Discussion