はじめに
Reactで学習記録アプリを作っていて、Vitestを使ってテストを書いていました。
アプリではSupabaseから学習記録を取得して、画面に表示しています。
そのままSupabaseに接続する形でテストを書いていましたが、これがかなり苦戦しました。
この記事では、外部サービスを使う非同期処理のテストでmockを使うべき理由と、実際にどう直したかをまとめます。
問題点
アプリ起動時にuseEffectでSupabaseからデータを取得していました。
これだと毎回テスト実行時にSupabaseへアクセスしてしまっていました。
export const getAllRecords = async () => {
const records = await supabase.from("study-record").select("*");
return records;
};
useEffect(() => {
const getRecords = async () => {
try {
const result = await getAllRecords();
setRecords(result.data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
getRecords();
}, []);
これによって、以下の問題が起こっていました。
- Supabaseのデータ状態によってテスト結果が変わる
- ネットワーク状態に左右される
- テストのたびに本物のデータを追加・削除してしまう
これ本当に大変でした。
解決
Vitestにはmockがあるので、これを使うことにしました。
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";
import {
addRecord,
deleteRecords,
getAllRecords,
} from "../utils/supabaseFunctions";
// SupabaseをMockに変更
vi.mock("../utils/supabaseFunctions", () => ({
getAllRecords: vi.fn(),
addRecord: vi.fn(),
deleteRecords: vi.fn(),
}));
// Mock用のデータを用意する
const mockRecords = [
{
id: "11111111-1111-4111-8111-111111111111",
title: "テスト学習内容01",
time: 1,
},
{
id: "22222222-2222-4222-8222-222222222222",
title: "テスト学習内容02",
time: 2,
},
];
describe("App", () => {
// Mock用のデータを初期化する
beforeEach(() => {
vi.clearAllMocks();
getAllRecords.mockResolvedValue({ data: mockRecords });
addRecord.mockResolvedValue();
deleteRecords.mockResolvedValue();
});
test("タイトルが表示されていること", async () => {
render(<App />);
expect(
await screen.findByRole("heading", { name: "学習記録アプリ" }),
).toBeInTheDocument();
});
test("学習内容と時間を追加することができること", async () => {
render(<App />);
const inputTodo = await screen.findByRole("textbox", { name: "学習内容" });
const inputTime = await screen.findByRole("spinbutton", {
name: "学習時間",
});
const addButton = await screen.findByRole("button", { name: "登録" });
fireEvent.change(inputTodo, { target: { value: "テスト学習内容03" } });
fireEvent.change(inputTime, { target: { value: "3" } });
fireEvent.click(addButton);
await waitFor(() => {
expect(screen.getAllByRole("listitem")).toHaveLength(3);
});
const items = screen.getAllByRole("listitem");
const lastItem = items[items.length - 1];
expect(lastItem).toHaveTextContent("テスト学習内容03");
expect(lastItem).toHaveTextContent("3時間");
expect(addRecord).toHaveBeenCalledWith("テスト学習内容03", "3");
});
//削除ボタンを押すと学習記録が削除される 数が1つ減っていることをテストする
test("学習内容と時間を削除することができること", async () => {
render(<App />);
const beforeItems = await screen.findAllByRole("listitem");
const beforeCount = beforeItems.length;
const deleteButtons = await screen.findAllByRole("button", {
name: "削除",
});
fireEvent.click(deleteButtons[0]);
const afterItems = await screen.findAllByRole("listitem");
const afterCount = afterItems.length;
expect(afterCount).toBe(beforeCount - 1);
});
// 入力をしないで登録を押すとエラーが表示される
test("入力をしないで登録を押すとエラーが表示されること", async () => {
render(<App />);
const todoInput = await screen.findByRole("textbox", { name: "学習内容" });
const timeInput = await screen.findByRole("spinbutton", {
name: "学習時間",
});
const addButton = await screen.findByRole("button", { name: "登録" });
fireEvent.change(todoInput, { target: { value: "" } });
fireEvent.change(timeInput, { target: { value: "0" } });
fireEvent.click(addButton);
expect(
await screen.findByText("必要な項目がありません"),
).toBeInTheDocument();
});
});
これで、外部サービスを使っていても環境に左右されることなくテストが実行できました。
まとめ
動くからといって、そのまま外部サービスに接続する形でテストを書くのは危ないと学びました。
テスト難しい!!!!
参考
ありがとうございました!