問題
アプリでは、中間テーブル(word_tags)を使って複数のテーブルからデータを取得し、テーブルごとに異なるデータを返している。
Jestでこのような複雑なデータ取得のモック(テーブルごとに返すデータを切り替える処理)の書き方が分からなかった。
実装
実際のコードでは、以下のように中間テーブル(word_tags)を経由してデータを取得している。
- 中間テーブル(word_tags)を使い、多対多の関係を表現し、単語とタグを紐付けている
- 3段階(words → word_tags → tags)でデータを取得し、最終的に「単語ごとにタグ名を表示」
(1)wordsテーブルから、ユーザーが持っている単語一覧を取得
const { data: words, error: wordsError } = await supabase
.from("words")
.select("*")
.eq("user_id", user ? user.id : null);
(2)word_tagsテーブルから、(1)で取得した単語に紐づくタグ情報を取得
※ wordIdsには事前処理を加えている
const { data: wordTags, error: wordTagsError } = await supabase
.from("word_tags")
.select("*")
.in("word_id", wordIds)
(3)tagsテーブルから、(2)で取得したタグIDに対応するタグ名を取得
※ tagIdsには事前処理を加えている
({ data: tags, error: tagsError } = await supabase
.from("tags")
.select("*")
.in("id", tagIds)
解決方法
テーブルごとに返すデータを切り替えるには、builder.then で分岐させる。
// supabaseをモック
jest.mock("../lib/supabaseClient");
// supabaseのモックデータをインポート
import { supabase } from "../lib/supabaseClient";
// 中略
type Word = {
id: number;
word_name: string;
mastered: boolean;
use: boolean;
explain: boolean;
};
// 単語
const userWords = [
{ id: 1, word_name: "hello", user_id: mockDummyUser.id, use: true, explain: true, mastered: false },
{ id: 2, word_name: "world", user_id: mockDummyUser.id, use: true, explain: true, mastered: false },
];
// タグ
const testTags = [
{ id: 1, tag_name: "greeting", user_id: mockDummyUser.id },
{ id: 2, tag_name: "noun", user_id: mockDummyUser.id },
{ id: 3, tag_name: "great", user_id: mockDummyUser.id },
];
// 単語とタグの紐付け(word_tagsテーブルのモック)
const testWordTags = [
{ word_id: 1, tag_id: 1 }, // hello → greeting
{ word_id: 1, tag_id: 2 }, // hello → noun
{ word_id: 2, tag_id: 1 }, // world → greeting
{ word_id: 2, tag_id: 2 },// world → noun
];
// 中略
const builder = {} as {
select: jest.Mock;
in: jest.Mock;
// 中略 eq, is, insert, single...など実装
then?: (resolve: (value: unknown) => unknown) => unknown;
};
// どのメソッド(selectやinなど)を呼んでも、常に同じbuilderオブジェクトを返すようにしている
// これにより、select().in().eq()... のようにメソッドチェーンができる
builder.select = jest.fn(() => builder);
builder.in = jest.fn(() => builder);
// 中略 eq, is, insert, single...など実装
// tableごとに返却するモックデータを変える
builder.then = (resolve) => {
// .mock.calls
// → このモック関数が「呼ばれた履歴(引数の配列)」を取得
// 例: supabase.from("words") と呼ばれると、mock.calls に [["words"]] が追加される
// .at(-1)
// → 呼び出し履歴の最後(最新)の1件を取得
// 例: 3回呼ばれていたら、3回目の引数配列が返る
// ?.[0]
// → その履歴(配列)の最初の引数を取得
const lastFromTable = (supabase.from as jest.Mock).mock.calls.at(-1)?.[0];
if (lastFromTable === "words") {
return resolve({ data: userWords, error: null });
}
if (lastFromTable === "word_tags") {
// console.log("testWordTags,", testWordTags,);
return resolve({ data: testWordTags, error: null });
}
if (lastFromTable === "tags") {
// console.log("testTags", testTags);
return resolve({ data: testTags, error: null });
}
return resolve({ data: [], error: null });
};
// 各テスト実行前に必ず実行される初期化処理
beforeEach(() => {
// Jestのモック関数(jest.fn())の呼び出し履歴や返り値設定をすべてリセット
// テスト間で前回の状態が残らず、毎回クリーンな状態でテストできる
jest.clearAllMocks();
// supabase.fromの動作を上書き(supabaseはfromから始まるので、実質全て上書き)
// supabase.from(...) が呼ばれたとき、「テストファイル内」の builder を返すようにする
(supabase.from as jest.Mock).mockImplementation(() => {
return builder;
});
});
補足
実装側のコードにconsole.logを仕込んでおくと、ターミナルでテストを実行したときに「SupabaseのAPI通信部分が、Jestのモックデータに正しく書き換わっているか」を確認できる。
本番APIではなく、テスト用のダミーデータが使われていることを目で見て確認できる。
終わりに
どこのデータを変えようとしているか、変えるためにはどこを変更するか考える必要があり、データの流れを1つ1つ細かく追うことが重要だと思いました。