Nuxt3 でページ単位の結合テストをしてみた(@nuxt/test-utils + MSW)
Nuxt3 で @nuxt/test-utils と MSW を使ったページ単位の結合テストを試してみたので手順をまとめました。
環境
用途 | パッケージ名 | バージョン |
---|---|---|
Web フレームワーク | Nuxt | 3.5.0 |
テストランナー | vitest | 0.31.1 |
テストライブラリ | @nuxt/test-utils Playwright |
3.5.1 1.34.3 |
ネットワークモック | MSW | 1.2.1 |
@nuxt/test-utils は Nuxt 向けのテストライブラリです。
ページ単位で SSR のレンダリングテストやブラウザを通したインタラクションテストが行なえます。
主に E2E やネットワークをモックしたページ単位の結合テスト向けです。
なお、2023/05/30 日現在 @nuxt/test-utils は開発中であり、API や仕様が大きく変わる可能性があります。
特にブラウザテストは Nuxt3 公式ドキュメントに取り組み中とだけ記載されていているためご注意ください。
- Nuxt3 testing
-
@nuxt/utils(旧ドキュメント?)
※ API 名など変わっているため参考程度に
手順
Nuxt プロジェクト作成
npm nuxi init [プロジェクト名] && cd [プロジェクト名]
yarn
テスト環境準備
テスト用ライブラリのインストール
@nuxt/test-utils はテストランナーとして jest / vitest をサポートしています。
またブラウザテストで内部的に Playwright を使っているので合わせて Playwright をインストールします。
yarn add -D vitest @nuxt/test-utils playwright
vitest 設定ファイル作成
インポートせずに vitest の関数を使えるように globals 設定を追加します。
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
},
});
TypeScript 用に vitest の型を設定
globals を有効にした場合は合わせて TypeScript 向けの型定義を追加します。
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"types": ["vitest/globals"]
}
}
MSW 準備
MSW インストール
timers は テスト時に以下のエラーが発生したためインストールしています。
yarn add -D msw timers
モック用の定義ファイル作成
一旦ファイルだけ作成し、 あとでモックの定義を追加していきます。
export const handlers = [];
CSR 用のサービスワーカーインストール
--save オプションで package.json にサービスワーカーの保存パスを記録して、更新があった場合に追跡できるようしています。
npx msw init public/ --save
セットアップ用のスクリプト作成
SSR 用
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
CSR 用
import { setupWorker } from "msw";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
Nuxt の MSW プラグイン作成
SSR か CSR かを判定してモックを起動するプラグインを作成します。
CSR 時に ~mocks/server をインポートするとエラーが出てしまうので defineNuxtPlugin の中で動的に import しています。
// エラーが発生するため defineNuxtPlugin の中で動的に import している
// import { worker } from '~/mocks/browser'
// import { server } from '~/mocks/server'
export default defineNuxtPlugin(async () => {
const { public: isApiMocked } = useRuntimeConfig();
if (!isApiMocked) {
return;
}
if (process.client) {
const { worker } = await import("~/mocks/browser");
await worker.start();
} else {
const { server } = await import("~/mocks/server");
server.listen();
}
});
アプリケーション作成
今回は以下の機能を持った簡単な ToDo アプリを作ります。
- ToDo の一覧取得
- ToDo の作成
- ToDo の完了/未完了の切り替え
API URL 生成ユーティリティ作成
endpoint('/todos') といった形で呼び出すとベース URL と引数を結合して API の URL を返すユーティリティを作成します。
モック定義とアプリケーション側で使用しています。
let _baseUrl = "";
export const setBaseURL = (baseUrl: string) => (_baseUrl = baseUrl);
export const endpoint = (path: string) => new URL(path, _baseUrl).toString();
上記ユーティリティのベース URL を設定するための Nuxt プラグインを作成します。
export default defineNuxtPlugin(() => {
const {
public: { baseUrl },
} = useRuntimeConfig();
setBaseURL(baseUrl);
});
baseUrl を runtimeConfig から取得できるように nuxt.config に baseUrl を追加します。
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
runtimeConfig: {
public: {
baseUrl: "http://localhost:3000/api",
},
},
});
ToDo の型ファイル作成
API が返す ToDo の型を定義します。
export type TodoDto = {
id: string;
title: string;
finishedAt: Date | null;
};
UUID インストール
モックデータの ID として UUID を使用しているためインストールします。
yarn add -D uuid @types/uuid
API の モック定義追加
以下の API のモックを定義します。
- GET /todos : Todo の一覧取得
- POST /todos : Todo の作成
- PUT /todos/is_finished : Todo の完了ステータスの変更
また初期データとして適当な Todo を inmemory で保持しています。
なお、初期データの ID を UUID で動的に生成してしまうと、SSR 時と CSR 時で
ID が変わってしまうので注意してください。
import { rest } from "msw";
import { v4 as uuid } from "uuid";
import { TodoDto } from "~/types/todo";
const todoStore: TodoDto[] = [
{
id: "c6ca9c61-0a56-4545-8952-b9035f482d7d",
title: "todo1",
finishedAt: new Date('2023-05-03'),
},
{
id: "92f8c99d-e42c-4a95-9ae4-2dab2f44e974",
title: "todo2",
finishedAt: null,
},
{
id: "0b75e34d-1b3a-4563-bf30-38aceee37d66",
title: "todo3",
finishedAt: null,
},
];
export const handlers = [
rest.get(endpoint("/todos"), async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
count: todoStore.length,
items: [...todoStore],
})
);
}),
rest.post(endpoint("/todos"), async (req, res, ctx) => {
const { title } = await req.json();
if (title.length < 0) {
throw new Error("title is required");
}
const id = uuid();
todoStore.push({ id, title, finishedAt: null });
return res(
ctx.status(200),
ctx.json({
id,
})
);
}),
rest.put(endpoint("/todos/is_finished"), async (req, res, ctx) => {
const [id, isFinished] = ["id", "isFinished"].map((queryName) =>
req.url.searchParams.get(queryName)
);
const todo = todoStore.find((todo) => todo.id === id);
if (!todo) {
throw new Error("todo not found");
}
todo.finishedAt = isFinished === "true" ? new Date() : null;
return res(ctx.status(200));
}),
];
ToDo リストのコンポーザブル作成
import { TodoDto } from "~/types/todo";
export const useTodoList = async () => {
const { data, refresh, ...fetchResult } = await useLazyFetch<{
count: number;
items: TodoDto[];
}>(endpoint("/todos"));
const addTodo = async (title: string) => {
await $fetch(endpoint("/todos"), {
method: "POST",
body: {
title,
},
});
await refresh();
};
const callChangeIsFinished = async (id: string, isFinished: boolean) => {
await $fetch(endpoint("/todos/is_finished"), {
method: "PUT",
params: {
id,
isFinished,
},
});
await refresh();
};
const openTodo = (id: string) => callChangeIsFinished(id, false);
const finishTodo = (id: string) => callChangeIsFinished(id, true);
return {
todoList: computed(() => data.value?.items ?? []),
count: computed(() => data.value?.count ?? 0),
...fetchResult,
addTodo,
openTodo,
finishTodo,
};
};
テストファイル作成
最初に setup で Nuxt の環境を起動します。
setup 後は $fetch や createPage を使ってページ単位でアクセスできます。
$fetch(url) を使うと対象のページの SSR レンダリング結果 HTML を string で取得できます。
createPage(url) を使うと対象のページの Page インスタンスを取得することができます。
この Page インスタンスは Playwright の Page インスタンスなので
Playwright の API を使って要素の取得やインタラクションテストを行うことができます。
なお、初回描画も createPage でもできると思うのですが使い分けやメリット・デメリットはよくわかっていません。
import { createPage, setup, $fetch } from "@nuxt/test-utils";
// import { expect } from "@playwright/test";
const wait = async (f: () => Promise<boolean>) => {
while (await f()) {
new Promise((r) => setTimeout(r, 100));
}
};
describe("todos/index", async () => {
await setup({
server: true,
});
it("SSR で必要な要素が描画されている", async () => {
const html = await $fetch("/");
console.error(html);
// タイトル
expect(html).toContain("Todo List");
// 追加ボタン
expect(html).toContain("Add");
// 初期データの todo
expect(html).toContain("todo1");
expect(html).toContain("todo2");
expect(html).toContain("todo3");
});
it("Todoリストの追加", async () => {
const page = await createPage("/");
const titleTextbox = page.getByRole("textbox", { name: "ToDoタイトル" });
// タイトルテキストボックスの初期状態は空
expect(await titleTextbox.inputValue()).toBe("");
// タイトルが未記入の場合は追加ボタンは無効
page.getByRole("button", { name: "Add", disabled: true });
const todoTitle = "牛乳を買いに行く";
await titleTextbox.click();
await page.keyboard.type(todoTitle);
await page.getByRole("button", { name: "Add" }).click();
// todo が反映されるまで待つ
// waitForResponse を使うと内部知識(APIのURLやAPIを呼ぶこと)を知ってしまうため避けた
await wait(
async () =>
(await page
.getByRole("listitem")
.filter({ hasText: todoTitle })
.count()) === 0
);
// todo がリストの最後に追加されていることを確認
expect(await page.getByRole("listitem").last().innerText()).toContain(
todoTitle
);
// Todo 追加後にはタイトルテキストボックスが空になっていることを確認
expect(
await page.getByRole("textbox", { name: "ToDoタイトル" }).inputValue()
).toBe("");
});
it("Todoリストの追加", async () => {
const page = await createPage("/");
const titleTextbox = page.getByRole("textbox", { name: "ToDoタイトル" });
// タイトルテキストボックスの初期状態は空
expect(await titleTextbox.inputValue()).toBe("");
// タイトルが未記入の場合は追加ボタンは無効
page.getByRole("button", { name: "Add", disabled: true });
const todoTitle = "牛乳を買いに行く";
await titleTextbox.click();
await page.keyboard.type(todoTitle);
await page.getByRole("button", { name: "Add" }).click();
// todo が反映されるまで待つ
// waitForResponse を使うと内部知識(APIのURLやAPIを呼ぶこと)を知ってしまうため避けた
await wait(
async () =>
(await page
.getByRole("listitem")
.filter({ hasText: todoTitle })
.count()) === 0
);
// todo がリストの最後に追加されていることを確認
expect(await page.getByRole("listitem").last().innerText()).toContain(
todoTitle
);
// Todo 追加後にはタイトルテキストボックスが空になっていることを確認
expect(
await page.getByRole("textbox", { name: "ToDoタイトル" }).inputValue()
).toBe("");
});
it("Todo ", async () => {
const page = await createPage("/");
// モックデータの todo2 は isFinished が false なのでチェックされていない
await page
.getByRole("checkbox", {
name: "todo2",
checked: false,
})
.click();
// チェックされることを確認
expect(
await page
.getByRole("checkbox", {
name: "todo1",
checked: true,
})
.isChecked()
).toBe(true);
// Todo2 は isFinished が true になっているはずなのでチェックされている
await page
.getByRole("checkbox", {
name: "todo2",
checked: true,
})
.click();
// チェックが外れることを確認
expect(
await page
.getByRole("checkbox", {
name: "todo2",
checked: false,
})
.isChecked()
).toBe(false);
});
});
テスト実行
yarn vitest
NODE v18 以上の場合は useFetch で MSW がモックしてくれないので NODE OPTIONS に --no-experimental-fetch を設定して実行します。
NODE_OPTIONS='--no-experimental-fetch' yarn vitest
詳しくはこちらの記事に記載しています。
あとがき
@nuxt/test-utils はまだ開発中なので API や仕様が変わる可能性がありますが、 ページ単位の結合テストを試すことができました。
ドキュメント等もまだ整備されていないため、本番環境では不安が残りますが今後のアップデートに期待です。
Discussion