はじめに
テストで欠かせないモックについて、「そもそもモックとは何か」
、「どのような仕組みか」
、「いつどのような場合に使えばいいのか」
について、理解があやふやな状態でした。
自分の理解、曖昧な状態を解消するために、学んだことをまとめてみました。
モックとは
モックの本来の(昔から使われていた)意味
こちらの記事より引用
モックとは
「モックアップ」の省略表現。
用語の中身としては
「外見だけ、それっぽく出来ています。中身は作ってないから動いたりはしないよ。見た目とかのイメージを確認するためだけに使ってね」な試作品とか模型のこと
です。システム開発の場合は、お客さまとの認識合わせのために作ります。
実際に作る前、お客さまに「こんな物を作りますよ~、イメージと合っていますか~?」確認を取ることで涙目になる事態を回避する
本来のモックの言葉の使い方は、こんな見た目で、こんな感じに動きますよ~という超簡単なプロダクト
を指すことと理解しました。
実務で、お客さまと認識合わせを行うため、画面やボタンを作り、このボタンを押して登録するとこの情報が画面表示されるなど、簡素化した画面と資料を作成したことを思い出しました。
→これがモックだったのかと理解しました。
Jestのモックと少し意味合いが違う?
との疑問も発生しました。
テストとしてのモック
こちらの記事より引用
最近では
部品を呼び出すときにやり取りする内容(リクエストの内容とか)を確認するためのスタブ(テスト対象から呼び出される部品の代わり)
を意図して「モック」という表現が使われることもあるみたいですね。
Jestでのモックの位置づけ
Jestのドキュメントにあるモック関数は上記の意味合いと理解しました。
ドキュメントより引用
関数が持つ実際の実装を除去したり、関数の呼び出し(また、呼び出しに渡されたパラメータも含め)をキャプチャしたり、new によるコンストラクタ関数のインスタンス化をキャプチャできます。
そうすることでテスト時のみの返り値の設定をすることが可能になります。
スタブとの違い
上記と似た位置づけで、業務ではよくスタブ
というワードで表現することがあります。
この場合、モック=スタブなのか、モックとスタブに違いがあるのかよくわからなくなりました。
スタブとは
こちらの記事より引用
テスト対象から呼び出される、本来の部品の代わり(で呼び出される方のやつ)
個人的には、モックとスタブは同義
と捉えてもいいのではと判断しました。
(みなさまの考えやご指摘がありましたらご教示いただけると嬉しいです。)
なぜモックを使うのか
私は今、学習記録アプリを作成しています。
ここで新規登録モーダルを開き、フォームに項目を入力、登録ボタン押下後、データが登録されることを確認するテストをしたいと考えていました。
最初に考えたテストコード
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "../App";
describe("App", () => {
it("登録ができる", async () => {
const user = userEvent.setup();
render(<App />);
// 新規登録ボタンをクリック
await user.click(await screen.findByTestId("new-record-button"));
// タイトルを入力
await user.type(await screen.findByTestId("title-input"), "Test Title");
// 時間を入力
const timeInput = await screen.findByTestId("time-input");
const input = timeInput.querySelector('input[role="spinbutton"]');
await user.clear(input!);
await user.type(input!, "60");
// 登録ボタンをクリック
await user.click(await screen.findByTestId("submit-button"));
// モーダルが閉じるのを待つ
await waitFor(() => {
expect(screen.queryByText("Modal Title")).not.toBeInTheDocument();
});
// 登録されたデータが表示されることを確認
expect(await screen.findByText("Test Title")).toBeInTheDocument();
expect(await screen.findByText("60")).toBeInTheDocument();
});
});
上記のテストコードでも確認することができますが、実際にDBに接続、データを登録、DBからデータを取得しないとテストコードとして成り立ちません。
テストを実行する度に、レコードが登録されてしまいます。
DBのサーバーが止まっているときや、実際の開発でまだDB接続ができないという場合もあり得ます。
その際は上記のテストコードは動かず、テストができないコードになります。
→外部システムに依存せずテストを行う、外部システムの代わりにモックを使うとはこういうことかと理解できました。
今回は、DBで理解しましたが、外部のAPIを利用するときなどでも使えると理解しました。
→APIが開発中で、実際のAPIの利用不可な場合など
Jestでのmockの使い方
1.jest.mock(...) 関数を使い、モック化を行う
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "../App";
+jest.mock("../lib/study-record", () => ({
+ GetAllStudyRecords: jest.fn(),
+ addStudyRecord: jest.fn(),
+}));
describe("App", () => {
it("登録ができる", async () => {
const user = userEvent.setup();
render(<App />);
*** 以下略 ***
jest.mock(モックしたいモジュールが存在するパス
, モックするモジュールを指定
)
-
GetAllStudyRecords: jest.fn()
→GetAllStudyRecordsを呼び出したときはモック(jest.fn())を返す -
addStudyRecord: jest.fn()
→addStudyRecordを呼び出したときはモック(jest.fn())を返す
2.モックしたモジュールをテストコードで使用する
モックしたDBデータ取得処理を使い、初期表示確認を行う
モックしたGetAllStudyRecordを使って初期表示確認をするテストコードを作成しました。
import { render, screen } from "@testing-library/react";
import App from "../App";
import { GetAllStudyRecords } from "../lib/study-record";
jest.mock("../lib/study-record", () => ({
GetAllStudyRecords: jest.fn(),
addStudyRecord: jest.fn(),
}));
describe("App", () => {
it("初期表示ができる", async () => {
// モックデータを準備
const mockData = [{ id: 1, title: "タイトル", time: 3 }];
// DBから値を取得したことにする(モックデータを返す)
(GetAllStudyRecords as jest.Mock).mockResolvedValue(mockData);
// Appコンポーネントをレンダリング
render(<App />);
// 初期表示のタイトルが存在することを確認
expect(await screen.findByTestId("title")).toBeInTheDocument();
screen.debug();
});
});
(GetAllStudyRecords as jest.Mock).mockResolvedValue(mockData);
- mockResolvedValueの引数に準備したデータを指定します
- GetAllStudyRecordsを呼んだときにmockResolvedValueに指定したデータを返します
screen.debug();
でレンダリングされたDOMを確認するとモックデータが表示されることを確認できました。
モックしたDB登録処理を使い、データ登録確認を行う
モックしたaddStudyRecordを使って登録処理を確認するテストコードを作成しました。
ここで学んだことは、ユーザー操作の前に、モックの設定を行う必要があるということです。
import {
act,
queryByTestId,
render,
screen,
waitFor,
} from "@testing-library/react";
import App from "../App";
import { addStudyRecord, GetAllStudyRecords } from "../lib/study-record";
import userEvent from "@testing-library/user-event";
jest.mock("../lib/study-record", () => ({
GetAllStudyRecords: jest.fn(),
addStudyRecord: jest.fn(),
deleteStudyRecordById: jest.fn(),
}));
describe("App", () => {
it("登録ができる", async () => {
const user = userEvent.setup();
// DBから値を取得(空データを返す)
(GetAllStudyRecords as jest.Mock).mockResolvedValue([]);
render(<App />);
// 表示データ(tbodyの行)がないことを確認
expect(await screen.findByTestId("title")).toBeInTheDocument();
expect(screen.queryByTestId("table-row")).not.toBeInTheDocument();
// 登録ボタンを押下
await user.click(await screen.findByTestId("new-record-button"));
// モーダルが表示されることを確認
expect(await screen.findByTestId("modal-title")).toBeInTheDocument();
// フォームを入力
await user.type(await screen.findByTestId("title-input"), "タイトル");
const timeInput = await screen.findByTestId("time-input");
const input = timeInput.querySelector('input[role="spinbutton"]');
await user.clear(input!);
await user.type(input!, "60");
// 先にDBに登録するデータを設定し、登録したことにする
(addStudyRecord as jest.Mock).mockResolvedValue([
{ id: "2", title: "タイトル", time: 60 },
]);
// 登録後にDBから再取得する可能性を考慮して、事前に設定
await waitFor(() => {
(GetAllStudyRecords as jest.Mock).mockResolvedValue([
{ id: "2", title: "タイトル", time: 60 },
]);
});
// 登録ボタン押下
await user.click(await screen.findByTestId("submit-button"));
// addStudyRecordが呼び出されたことを確認
await waitFor(() => {
expect(addStudyRecord).toHaveBeenCalled();
});
// テーブルに登録データが表示されることを確認
expect(await screen.findByText("タイトル")).toBeInTheDocument();
expect(await screen.findByText("60")).toBeInTheDocument();
screen.debug();
});
});
ここでは、登録ボタンを押下する前に、
DBに登録するデータを設定し、登録したことにする
DBから値を取得(登録データを返す)
を設定をしています。
なぜ、先に設定しないといけないのでしょうか?
本来の処理は、「登録ボタン押下 → addStudyRecordでデータ登録 → GetAllStudyRecordsでデータ取得」
の流れで進みます。
テスト内で登録ボタン押下後、GetAllStudyRecordsのモックを設定した場合、実際のGetAllStudyRecordsが呼び出され、モックを設定した関数は実行されないという状態が発生します。
→モックの設定が間に合わず、実際のGetAllStudyRecords関数(本物)が呼ばれてしまいます。
登録ボタン押下の操作の前にモックを設定しておくことで、操作後に実行される関数はモック化された関数を呼ぶことができます。
そのため、ユーザー操作を行う前にモックの戻り値を設定することが重要です。
モックしたDB削除処理を使い、削除されることを確認する
モックしたdeleteStudyRecordByIdを使って削除処理を確認するテストコードを追加しました。
import { render, screen, waitFor } from "@testing-library/react";
import App from "../App";
import {
addStudyRecord,
deleteStudyRecordById,
GetAllStudyRecords,
} from "../lib/study-record";
import userEvent from "@testing-library/user-event";
jest.mock("../lib/study-record", () => ({
GetAllStudyRecords: jest.fn(),
addStudyRecord: jest.fn(),
deleteStudyRecordById: jest.fn(),
}));
describe("App", () => {
it("削除ができること", async () => {
const user = userEvent.setup();
// DBから削除前の値を取得
(GetAllStudyRecords as jest.Mock).mockResolvedValue([
{ id: "2", title: "覚えてないよ", time: 300 },
{ id: "3", title: "覚えたよ", time: 100 },
]);
render(<App />);
// 削除前のデータが表示されること
expect(await screen.findByText("覚えたよ")).toBeInTheDocument();
expect(await screen.findByText("100")).toBeInTheDocument();
// 前もってDB削除したことにする
(deleteStudyRecordById as jest.Mock).mockResolvedValue([
{ id: "2", title: "覚えてないよ", time: 300 },
]);
// 前もって削除後のDB取得をする
(GetAllStudyRecords as jest.Mock).mockResolvedValue([
{ id: "2", title: "覚えてないよ", time: 300 },
]);
// 削除ボタン押下
const deleteButtons = await screen.findAllByTestId("delete-button");
await user.click(deleteButtons[1]);
// 削除処理が呼び出されたことを確認
await waitFor(() => {
expect(deleteStudyRecordById).toHaveBeenCalled();
});
// 削除したデータが表示されないこと
expect(screen.queryByText("覚えたよ")).not.toBeInTheDocument();
expect(screen.queryByText("100")).not.toBeInTheDocument();
});
});
参考:DB取得、登録、削除の実際の処理
import { supabase } from "../utils/supabase";
import { Record } from "../domain/record";
export const GetAllStudyRecords: () => Promise<Record[]> = async () => {
const { data, error } = await supabase.from("study-record").select();
if (error) {
throw new Error(error.message);
}
const StudyRecords = data.map(
(record) =>
new Record(record.id, record.title, record.time, record.created_at)
);
return StudyRecords;
};
export const addStudyRecord = async (data: Partial<Record>) => {
const { error } = await supabase
.from("study-record")
.insert({ title: data.title, time: data.time });
if (error) {
throw new Error(error.message);
}
};
export const deleteStudyRecordById = async (id: string) => {
const { error } = await supabase
.from("study-record")
.delete()
.eq("id", id)
.select();
if (error) {
throw new Error(error.message);
}
おわりに
ユーザーの操作、実際に内部の処理を考慮しながらテストを書かなければいけないことを再認識しました。
参考