🧪

Nuxt3 でページ単位の結合テストをしてみた(@nuxt/test-utils + MSW)

2023/05/30に公開

Nuxt3 で @nuxt/test-utils と MSW を使ったページ単位の結合テストを試してみたので手順をまとめました。
https://github.com/harusame0616/nuxt3-page-integration-test

環境

用途 パッケージ名 バージョン
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 公式ドキュメントに取り組み中とだけ記載されていているためご注意ください。

手順

Nuxt プロジェクト作成

npm nuxi init [プロジェクト名] && cd [プロジェクト名]
yarn

テスト環境準備

テスト用ライブラリのインストール

@nuxt/test-utils はテストランナーとして jest / vitest をサポートしています。
またブラウザテストで内部的に Playwright を使っているので合わせて Playwright をインストールします。

yarn add -D vitest @nuxt/test-utils playwright

vitest 設定ファイル作成

インポートせずに vitest の関数を使えるように globals 設定を追加します。

vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
  },
});

TypeScript 用に vitest の型を設定

globals を有効にした場合は合わせて TypeScript 向けの型定義を追加します。

tsconfig.json
{
  // https://nuxt.com/docs/guide/concepts/typescript
  "extends": "./.nuxt/tsconfig.json",
  "compilerOptions": {
    "types": ["vitest/globals"]
  }
}

MSW 準備

MSW インストール

timers は テスト時に以下のエラーが発生したためインストールしています。
https://github.com/mswjs/msw/discussions/1440

yarn add -D msw timers

モック用の定義ファイル作成

一旦ファイルだけ作成し、 あとでモックの定義を追加していきます。

mocks
export const handlers = [];

CSR 用のサービスワーカーインストール

--save オプションで package.json にサービスワーカーの保存パスを記録して、更新があった場合に追跡できるようしています。

npx msw init public/ --save

セットアップ用のスクリプト作成

SSR 用

mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

CSR 用

mocks/browser.ts
import { setupWorker } from "msw";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

Nuxt の MSW プラグイン作成

SSR か CSR かを判定してモックを起動するプラグインを作成します。
CSR 時に ~mocks/server をインポートするとエラーが出てしまうので defineNuxtPlugin の中で動的に import しています。

plugins/msw.ts
// エラーが発生するため 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 を返すユーティリティを作成します。
モック定義とアプリケーション側で使用しています。

utils/api.ts
let _baseUrl = "";

export const setBaseURL = (baseUrl: string) => (_baseUrl = baseUrl);
export const endpoint = (path: string) => new URL(path, _baseUrl).toString();

上記ユーティリティのベース URL を設定するための Nuxt プラグインを作成します。

plugins/1_endpoint.ts
export default defineNuxtPlugin(() => {
  const {
    public: { baseUrl },
  } = useRuntimeConfig();

  setBaseURL(baseUrl);
});

baseUrl を runtimeConfig から取得できるように nuxt.config に baseUrl を追加します。

nuxt.config.ts
// https://nuxt.com/docs/api/configuration/nuxt-config

export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      baseUrl: "http://localhost:3000/api",
    },
  },
});

ToDo の型ファイル作成

API が返す ToDo の型を定義します。

types/todo.ts
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 が変わってしまうので注意してください。

mocks/handlers.ts
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 リストのコンポーザブル作成

composables/useTodoList.ts
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 でもできると思うのですが使い分けやメリット・デメリットはよくわかっていません。

app.spec.ts
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

詳しくはこちらの記事に記載しています。
https://zenn.dev/harusame0616/articles/d497a84a6cb792#node-18-以上で-ssr-時に-%24fetch-が-mock-されない

あとがき

@nuxt/test-utils はまだ開発中なので API や仕様が変わる可能性がありますが、 ページ単位の結合テストを試すことができました。
ドキュメント等もまだ整備されていないため、本番環境では不安が残りますが今後のアップデートに期待です。

GitHubで編集を提案

Discussion