Webフロントエンド開発(React)でテストを書きながらリファクタリングする方法
テスト書いてますか?
はじめに
アプリケーションの開発をしていると、なるべく早い時期からテストを書いたほうが良いとわかっていても、まずは機能を作ることに注力してしまいがちです。ついテストの作成を実装の後にまわすことになりますが、メインブランチにマージする段階では機能の実装とともにテストも含めたいものです。
また、テストコードを書くことはアプリケーションコードをどのように書けばテストしやすいかを考えることにも繋がり、結果的にアプリケーションコードに責務分割された構造化をもたらすことになります。アプリケーションコードの設計のためにもテストを書くことを活用したいものです。
本記事ではベタ書きで作成済みのアプリケーションコードにテストコードを追加し、段階的にアプリケーションコードとテストコードのリファクタリングを進めていくプロセスを紹介します。
1. ベタ書きのアプリケーションの概要
最初にテストを書く対象となるベタ書きしたアプリケーションについて説明します。
仕様
- ブラウザで動作するWebアプリケーションです。http://localhost:3000/ で動作します。
- Todoの一覧をWEB APIから取得し、テーブルで一覧表示します。
- 使用している主なライブラリは次のとおりです。
ファイルとディレクトリの構成
ファイルとディレクトリの構成は次のようになっています。
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のスキーマ定義
スキーマ定義は次のとおりです。
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コンポーネントをそのままエクスポートして使っています。
import { TodosPage } from "@/components/pages/TodosPage";
export default TodosPage;
TodosPage ディレクトリ直下のindex.tsファイルではTodosPage.tsxファイルのTodosPageコンポーネントをエクスポートしています。
export { TodosPage } from "./TodosPage"
具体的なTodosPageコンポーネントの実装は次のとおりです。
"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のドキュメントを参照してください。
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ファイルを作成して共通処理のインポートを行っておきます。
import "@testing-library/jest-dom/vitest";
また、エディタがテストライブラリを認識するように、tsconfig.json
に"types": ["vitest/globals", "@testing-library/dom", "@testing-library/jest-dom"]
を追記します。
{
"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 コマンドを追記します。
{
"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つの案が考えられます。
- SWRConfigでfallbackを使い、useSWRが返す値を事前に用意する。
- TodosApiのgetTodosメソッドにモックを適用し、返す値を事前に用意する。
- fetchをモックして返すHTTPレスポンスを事前に用意する。
案1は準備が楽ですが、useSWRのfetcherの中身の実装を検証できません。
今回のケースではTodosApiはOpenAPIスキーマから生成したコードなので、getTodosメソッドの実装までを検証する必要がないとみなして案2を採用します。
具体的には次のように vitest の spyOnを利用します。spyOnの詳細はvitestのドキュメントを参照してください。
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コンポーネントのライフサイクルは次のようになります。
- 初期状態(マウント時)
- データ取得完了
そのため、render メソッドで描画直後は必ずTodoデータが存在しないことになり、取得が完了するまでアサーションの実行を待ってあげる必要があります。
この待機には @testing-library/react の waitFor を使います。
具体的には次のようにして期待する実行結果が得られることを待機するようにします。
await waitFor(() => expect(r.getAllByTestId("todosListRow")).toHaveLength(1));
SWRを使用している場合の対処法
今回のTodosPageコンポーネントでは内部でSWRを利用してWEB APIからのデータ取得をキャッシュしています。
そのため、テストケースごとにキャッシュされたデータが使われないように対応する必要があります。
具体的には、render の wrapper として キャッシュさせないプロバイダを指定した SWRConfigを適用します。詳細はSWRのドキュメントを参照してください。
render(<TodosPage />, {
wrapper: ({ children }) => (
<SWRConfig
value={{
provider: () => new Map(),
}}
>
{children}
</SWRConfig>
);
TodosPageのテストコード
ここまでの対応を考慮したTodosPageのテストコードは次のようになります。
render や spyOn など毎回行う対応はテスト内の関数にまとめて整理してあります。
データの件数や状態によっての表示の切り分けもテストケースに盛り込んでいます。
テストで使用しているアサーションについては vitestのドキュメント と @testing-library/jest-dom のドキュメントを参照してください。
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からテーブル部分を移植します。
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を作ってエクスポートします。
export { TodosList } from "./TodosList"
TodosPageコンポーネントではTodosListを使う形にコードを書き換えます。
"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コンポーネントから移植してきます。
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でエクスポートしておきます。
export { TodosListRow } from "./TodosListRow"
TodosListではTodosListRowを使うように修正します。
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 を外します。
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 の待機は外せます。
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のテスト自体も移動した分を減らします。
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から移植してきます。
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でエクスポートします。
export { useGetTodos } from "./useGetTodos"
TodosPageではuseGetTodosフックを使うように修正します。
"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による待機を行います。
それらを踏まえたテストは次のとおりです。
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
に保存されるので、コードのどこが実行されているかを確認することができます。
分岐網羅などでテストする条件が足りているかを確認するのに参考になります。
Discussion