はじめに
こんにちは!POMです。
React×TypeScriptでWebアプリの個人開発をしており、
Vitestでのテスト実装中に
表題のエラーがでましたので、解決方法を備忘録として残します!
問題
記録系のWebアプリを開発していて、
「既に登録済みの記録を編集して内容が更新されるか」
といった内容のテストを実装しました。
実装後にnpm run testを実行した際の内容が下記です。
AssertionError: expected "vi.fn()" to be called with arguments: [ ObjectContaining{…} ]
Received:
1st vi.fn() call:
[
- ObjectContaining {
- "formData": ObjectContaining {
+ {
+ "formData": {
+ "after_photo_url": undefined,
+ "before_photo_url": undefined,
+ "category": "nail",
+ "cost": 1000,
+ "detail": "テストです",
+ "done_at": 2026-03-31T15:00:00.000Z,
+ "salon_name": "test salon",
+ "staff_name": "テスト花子さん",
"title": "テストタイトル編集",
},
"logId": "test-id-1",
+ "photoUrls": {
+ "after": null,
+ "before": null,
+ },
+ "userId": "test-user-id",
+ },
+ {
+ "client": QueryClient {},
+ "meta": undefined,
+ "mutationKey": undefined,
解決方法
【修正前のテストファイル】
import { render } from "@/test-utils/render";
import { screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { EditLogPage } from "@/features/log/EditLogPage";
const mockNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
useParams: () => ({ id: "test-id-1" }),
};
});
// テスト用のダミーユーザーを返す
vi.mock("@/lib/supabase/client", () => ({
supabase: {
auth: {
getUser: vi.fn().mockResolvedValue({
data: { user: { id: "test-user-id" } },
}),
},
storage: {
from: vi.fn().mockReturnValue({
upload: vi.fn().mockResolvedValue({ error: null }),
}),
},
},
}));
const baseMockLog = {
after_photo_url: null,
before_photo_url: null,
category: "nail" as const,
cost: 1000,
created_at: "2026-04-01 09:16:52.56655+00",
detail: "テストです",
done_at: "2026-04-01",
id: "test-id-1",
next_interval_days: null,
salon_name: "test salon",
staff_name: "テスト花子さん",
title: "テストタイトル",
user_id: "test-user-id",
};
const { mockCreateLog, mockUpdateLog, mockFetchLog } = vi.hoisted(() => {
return {
mockCreateLog: vi.fn(),
mockUpdateLog: vi.fn(),
mockFetchLog: vi.fn(),
};
});
vi.mock("@/api/logs", () => ({
createLog: mockCreateLog,
updateLog: mockUpdateLog,
fetchLog: mockFetchLog,
}));
const user = userEvent.setup();
describe("EditLog", () => {
beforeEach(() => {
mockNavigate.mockReset();
mockFetchLog.mockReset();
mockFetchLog.mockResolvedValue({});
mockUpdateLog.mockReset();
mockUpdateLog.mockResolvedValue(baseMockLog);
});
test("タイトルが「記録編集」であること", async () => {
render(<EditLogPage />);
expect(await screen.findByText("記録編集")).toBeInTheDocument();
});
test("編集して登録すると更新されること", async () => {
mockFetchLog.mockResolvedValue({ ...baseMockLog });
render(<EditLogPage />);
const titleInput = await screen.findByLabelText(/タイトル/);
await user.clear(titleInput);
await user.type(titleInput, "テストタイトル編集");
await user.click(screen.getByRole('button', { name: '記録を更新する' }));
await waitFor(() => {
expect(mockUpdateLog).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ title: "テストタイトル編集" }),
logId: "test-id-1",
}));
expect(mockNavigate).toHaveBeenCalledWith('/log-timeline');
});
});
});
useNavigateをモックしているため、
実際の画面遷移は起きないの(アプリ上の動きとしては編集すると一覧画面に遷移する仕様としていました)で、
「正しい内容で更新APIが呼ばれた」ことを下記のように確認しようとしました。
expect(mockUpdateLog).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ title: "テストタイトル編集" }),
logId: "test-id-1",
})
);
今回は更新APIに対してTanStack Query(v5)を導入して状態管理しています。
// 既存の記録を更新するカスタムフック
export function useUpdateLog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateLog,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["logs"] });
},
});
}
エラー内容を見ると、
mutationFnに第2引数としてコンテキストオブジェクト { client, meta, mutationKey }を渡しているため、toHaveBeenCalledWith(arg1)が引数の数不一致で失敗していました。
公式ドキュメントにも書いています。
mutationFn: (variables: TVariables, context: MutationFunctionContext) => Promise
Required, but only if no default mutation function has been defined
A function that performs an asynchronous task and returns a promise.
variables is an object that mutate will pass to your mutationFn
context is an object that mutate will pass to your mutationFn. Contains reference to QueryClient, mutationKey and optional meta object.
mutationFn(variables, context) の形式で呼び出されており、contextには{ client, meta, mutationKey }が入りますが、テストファイルではこの部分をexpect.anything()にしました。
expect.anything
Type: () => any
This asymmetric matcher matches anything except null or undefined. Useful if you just want to be sure that a property exists with any value that's not either null or undefined.
【修正後のテストファイル】
test("編集して登録すると更新されること", async () => {
mockFetchLog.mockResolvedValue({ ...baseMockLog });
render(<EditLogPage />);
const titleInput = await screen.findByLabelText(/タイトル/);
await user.clear(titleInput);
await user.type(titleInput, "テストタイトル編集");
await user.click(screen.getByRole('button', { name: '記録を更新する' }));
await waitFor(() => {
expect(mockUpdateLog).toHaveBeenCalledWith(
expect.objectContaining({
formData: expect.objectContaining({ title: "テストタイトル編集" }),
logId: "test-id-1",
+ }),
+ expect.anything() // TanStack QueryがmutationFnに渡すコンテキスト引数
);
expect(mockNavigate).toHaveBeenCalledWith('/log-timeline');
});
});
このように修正したところ、エラーは解消されました。
おわりに
Tanstack Queryのような便利なライブラリを導入すると、
そちらの仕様もきちんと公式ドキュメントなどで
確認する必要性が出てきますね。
地道にエラーと向き合います。
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてくださ!
▼▼▼