🎬

mswjs/data で広がるテスト戦略

2022/01/21に公開

本稿は MSW(Mock Service Worker)エコシステムのうちの一つ mswjs/data を試してみた記事です。記事中のサンプルコードは以下で公開しています。
https://github.com/takefumi-yoshii/msw-data-sandbox

mswjs/data とは

仮想 DB をブラウザ(インメモリ)に展開する、データモデリング・リレーションライブラリです。MSW 単体ではハードコードされたフィクスチャのみの定義となりますが、mswjs/data を併用することで、データ駆動型 API モックを作成することが可能になります。使い始めは簡単で、以下の様にスキーマを定義しfactory関数で DB インスタンスを生成します。

import { factory, primaryKey } from '@mswjs/data'

const db = factory({
  user: {
    id: primaryKey(String),
    firstName: String
    age: Number
  }
})

ORM 風な API

レコード作成は、以下の様に db インスタンスを経由し user テーブルに登録すれば OK。

const user = db.user.create({
  id: "0",
  firstName: "John",
  age: 18,
});

あとは以下の様にテーブルを操作します。クエリのオペレーターもいくつか用意されているので、ある程度実現したいことはできるのではないでしょうか。

const user = db.user.findFirst({
  where: {
    id: {
      equals: "abc-123",
    },
  },
});
const users = db.user.findMany({
  where: {
    followersCount: {
      gte: 1000,
    },
  },
});

API 詳細はリポジトリの READMEを見ていただいた方が良いので割愛します。

MSW インテグレーション

MSW エコシステムならではの面白い機能がtoHandlers関数です。以下の様にuserテーブルを定義し.toHandlers('rest')とするだけで、CRUD API(ハンドラー関数群)が出来上がってしまいます。

import { factory, primaryKey } from "@mswjs/data";

const db = factory({
  user: {
    id: primaryKey(String),
    firstName: String,
  },
});

// Generates REST API request handlers.
db.user.toHandlers("rest");

生成される MSW ハンドラー関数は以下の通りです。

  • GET /users/:id(「id」はモデルの主キー)ID でユーザーを返す
  • GET /users すべてのユーザーを返す(ページネーションのサポートあり)
  • POST /users 新しいユーザーを作成
  • PUT /users/:id 既存ユーザーを ID で更新します。
  • DELETE /users/:id ID で既存のユーザーを削除します。

toHandlers関数はまだ荒削りな API ですが「UI をとにかく作りたいから、即興のモックサーバーが欲しい」という場合には良いかもしれません。.toHandlers('graphql')とすることで、GraphQL API モックハンドラーの生成も可能なようです。すごいですね。

mswjs/data の採用モチベーション

UI のみをテストしたいのであれば MSW 単体で十分です。mswjs/data が活きるのは、画面を横断する E2E テストケースでしょう。MSW 単体で開発をすすめる場合、操作結果による状態は引き継がれません。別画面操作による更新内容は反映されないため「更新した値が別画面で反映されているか?」というテスト観点が疎かになりがちです。

この感覚で開発をすすめてしまうと、いざ API サーバーと結合した時になってはじめて「値が正しく更新されない」というバグに気がつきます。SWR などのキャッシュを活用したアプリケーション開発では、API が呼ばれデータは更新されているものの、レンダリングに反映されていないバグに遭遇しがちです。こういった画面をまたぐ機能と SWR などのキャッシュは割と鬼門です(筆者体験談)実際の API サーバーとの結合前に、こういったライブラリを活用すれば、バグを未然に防げる可能性があると思います。そのほか、以下観点が採用モチベーションになると思います。

  • 実際の DB・バックエンドが揃う前に、アプリケーション E2E テストが実施できる
  • 従来のテストインフラ整備と比較し、軽量・手軽に E2E テストが実施できる

mswjs/data を利用したテストは、あくまでフロントエンドに閉じたモックサーバーを利用したものです。E2E と呼ぶにはいささか大袈裟なので、以下 User flow Testing と呼ぶことにします。

User flow Testing の紹介

Playwrihgt を使った User flow Testing のコードをみていきます。以下のテストケースは、記事を書いて投稿するだけの単純な CRUD アプリケーションです。このアプリケーションとテストは、フロントエンドのコードのみで完結しています。

import { expect, test } from "@playwright/test";

test("更新画面でタイトルを編集すると、記事詳細画面でタイトルが更新される", async ({
  page,
}) => {
  const expectText = "TEST";
  await page.goto("http://localhost:3000/posts");
  await page.locator("text=Lorem ipsum").click();
  await page.locator("text=edit").click();
  await page.fill("[name=title]", expectText);
  await page.locator("button").click();
  await page.waitForNavigation();
  const locator = page.locator("h2");
  await expect(locator).toHaveText(expectText);
});

test("新規作成画面で記事作成すると、記事一覧画面に記事リンクが並ぶ", async ({
  page,
}) => {
  const expectText = "TEST";
  await page.goto("http://localhost:3000/posts");
  await page.locator("text=create new").click();
  await page.fill("[name=title]", expectText);
  await page.locator("button").click();
  const locator = page.locator("[data-testid=list] li:last-child");
  await expect(locator).toHaveText(expectText);
});

初期データの投入

「初期データの投入(seed)がテストケース毎に実施可能か?」という観点は気になるところですが、mswjs/data 公式ではまだ方法論が示されていません。そこで、Playwrihgt で初期データを投入する方法を模索したところ、以下の様なコードで初期データの投入が実現できました。

test("seeding", async ({ page }) => {
  const expectText = "TEST";
  await page.goto("http://localhost:3000/posts");
  await page.evaluate((posts) => {
    const { seed } = window.msw;
    seed({ posts });
  }, posts);
  const locator = page.locator("[data-testid=list] li:last-child");
  await expect(locator).toHaveText(expectText);
  await takeScreenshot(page, "seeding");
});

これは MSW 公式ドキュメントで紹介されている Cypress 向けのインターセプト方法を参考にしています。下準備として、Next.js アプリケーション側で window にseed関数(初期データのセットアップ関数)を公開しておく必要があります。

import { rest, setupWorker } from "msw";
import { seed } from "../db";
import { handlers } from "./handlers";

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

window.msw = {
  worker,
  rest,
  seed,
};
import { drop, factory } from "@mswjs/data";
import { defaultValues, dictionary } from "./models";

export const db = factory(dictionary);

export function seed(values = defaultValues) {
  drop(db);
  const nextValues = {
    ...defaultValues,
    ...values,
  };
  nextValues.posts.map((post) => {
    db.post.create(post);
  });
}

Seed 関数の再利用

昨今、Storybook を中心とした 「テストコード資材の再利用」 がフロントエンドテスト戦略では欠かせなくなってきています。Storybook・Jest でもこの Seed 関数が再利用できたので、補足として紹介します。

Storybook(UI Testing)

Story 毎に初期値を投入できるよう、Decorator の仕組みを使います。高階関数とすることで、初期値投入ができます(デフォルト初期値は.storybook/preview.jsで投入しておきます)

import { defaultValues } from "@/mock/db/models";
import type { Story } from "@storybook/react";
import React from "react";
import { seed } from "../db";

export const mswDbSeed =
  (values = defaultValues) =>
  (StoryComponent: Story) => {
    seed(values);
    return <StoryComponent />;
  };
import { mswDbSeed } from "@/mock/storybook/mswDbSeed";
import { initialize, mswDecorator } from "msw-storybook-addon";
import { handlers } from "../src/mock/msw/handlers";

initialize();
export const decorators = [mswDecorator, mswDbSeed()];
export const parameters = { msw: { handlers } };

Story 毎の初期値投入は以下の様にすれば OK です。

export const Default: Story = {
  args: { id: "0" },
  decorators: [
    mswDbSeed({
      posts: [
        {
          id: "0",
          title: "seed example",
          body: "Hello world",
        },
      ],
    }),
  ],
};

Jest(Integration Testing)

@storybook/testing-reactcomposeStoriesを利用し、Storybook に登録した Story をアサーションしています。バリデーション表示のテストケースは、CSF3.0 の play function を使っています(便利)

import { setupMockServer } from "@/lib/msw";
import { seed } from "@/mock/db";
import { handlers } from "@/mock/msw/handlers";
import { composeStories } from "@storybook/testing-react";
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import React from "react";
import * as stories from "./index.stories";

const { Default, InputError } = composeStories(stories);

describe("src/templates/posts/[id]/edit/index.test.tsx", () => {
  beforeAll(() => seed());
  setupMockServer(...handlers);
  describe("初期表示", () => {
    test("正常時", async () => {
      render(<Default />);
      expect(await screen.findByText("POST")).toBeInTheDocument();
    });
  });
  describe("バリデーション表示", () => {
    test("タイトルが空の時、エラー文言が表示される", async () => {
      const { container, findByRole } = render(<InputError />);
      await InputError.play({ canvasElement: container });
      expect(await findByRole("alert")).toBeInTheDocument();
    });
  });
});

Jest のテストケースでは以下の一行で、全てのケースでデフォルト初期値が投入されます。必要に応じて、この関数実行をテストケース毎実施前に行えば OK。

beforeAll(() => seed());

テストコード資材の再利用

実は、composeStoriesを使っていればmswDbSeed関数で投入したデータがそのまま Jest のテストコードに引き継がれるため、Jest ではアサーションするのみで済みます。「テストを書こうとすれば Storybook が自ずと充実する」という流れがここで生まれ、保守メンテナンス工数を下げることに繋がります。

今回紹介した User flow Testing も、真の E2E テストに再利用することは容易なはず。そういった観点で、地続きでテスト戦略を練ると良いと思います。

まとめ・所感

User flow Testing で注意しなければいけないことは、gotoを使った遷移を含めるとデータが初期化されてしまう、という点です。SPA 遷移でなければ、そもそも mswjs/data の値は保持されません。完全な E2E テストではないので、こういった制約はあります。

また、簡単な CRUD であれば重宝するかもしれませんが、実際のアプリケーションはそうもいきません。バッグエンド実装の詳細を意識したモックサーバー作成となると、保守工数もかさむでしょう。しかし、テストケースによっては重厚なテストインフラを用意するよりも気軽に実施できる点は評価できるので、引き続き模索していきたいと思います。

Discussion