mizdra's blog

ぽよぐらみんぐ

GraphQL のレスポンスのモックデータの作成を補助する TypeScript ライブラリを作った

GraphQL を使って Web アプリケーションを実装していると、GraphQL API のリクエストをモックしたいことがあると思います。

  • ユニットテストのために、ダミーレスポンスに差し替えたい
  • ビジュアルリグレッションテストのために、ダミーレスポンスに差し替えたい
  • Storybook で story を書くために、ダミーレスポンスに差し替えたい
  • バックエンドの resolver 実装を待たずにフロントエンド側の開発を始めるために、ダミーレスポンスに差し替えたい

一般には GraphQL Client にモックするための機能が実装されてるので、そうしたものを使うことが多いと思います。

zenn.dev

また最近は Client よりも外側のレイヤーでリクエストを interrupt してモックする「msw」を使うケースも増えてきてます *1

blog.engineer.adways.net

モックデータ作成に手間がかかる

モックする時によく煩わしく感じるのが、モックデータの作成です。例えばビジュアルリグレッションテストのために GraphQL API をモックする場合、期待通りにぽページが描画されるようなモックデータを作成する必要があります。

import { graphql } from "msw";

// msw を使ったモックの例
export const handlers = [
  graphql.query("BookListPageQuery", (req, res, ctx) => {
    return res(
      ctx.data({
        books: [
          {
            __typename: "Book",
            id: "Book-0",
            title: "ゆゆ式 1巻",
            author: {
              __typename: "Author",
              id: "Author-0",
              name: "三上小又",
            },
          },
          {
            __typename: "Book",
            id: "Book-0",
            title: "ゆゆ式 2巻",
            author: {
              __typename: "Author",
              id: "Author-0",
              name: "三上小又",
            },
          },
        ],
      })
    );
  }),
  graphql.query("BookDetailPageQuery", (req, res, ctx) => {
    return res(
      ctx.data({
        book: {
          __typename: "Book",
          id: "Book-0",
          title: "ゆゆ式 1巻",
          author: {
            __typename: "Author",
            id: "Author-0",
            name: "三上小又",
          },
        },
      })
    );
  }),
];

ただ、このようにモックデータをベタ書きしていくと、コードの重複が増えていきます。例えば上記の例では、id: "Book-0" の node と id: "Author-0" の node が重複しています。今くらいの行数なら大したことはないですが、数が増えてくると可読性やメンテナンス性に影響が出てきます。

一般にはこうした問題を回避するために、type 単位のダミーレスポンスを作成する補助関数 (factory みたいなやつ) を自作している人が多いんじゃないかと思います。

// 事前に graphql-code-generator の typescript plugin で type ごとの型定義を生成しておく
import { Book, Author } from './__generated__/type.ts';
// Book: `{ __typename: 'Book', id: string, title: string, author?: Author }`

function fakeBook(args?: Partial<Book>): Book {
  return {
    __typename: 'Book',
    id: 'Book-0',
    title: 'ゆゆ式 0巻',
    ...args,
  };
}
function fakeAuthor(args?: Partial<Author>): Author {
  return {
    __typename: 'Author',
    id: 'Author-0',
    name: '0上小又',
    ...args,
  };
}

// こうやって使う
fakeBook({
  title: 'ゆゆ式 1巻',
  author: fakeAuthor({
    name: '1上小又',
  }),
});
// {
//   __typename: 'Book',
//   id: 'Book-0',
//   title: 'ゆゆ式 1巻',
//   author: { __typename: 'Author', id: 'Author-0', name: '1上小又' },
// }

これで幾分か楽になりますが、factory 関数を時前で実装したりメンテナンスしていくのは手間です。また、「id をオートインクリメントしつつモックデータを作成したい」「Book を N 個詰め込んだ配列を作って欲しい」など等色々な要件が出てくると、factory 関数の実装が複雑になってきます。

世の中には factory 関数を自動生成してくれる graphql-codegen-typescript-mock-data という GraphQL Code Generator のプラグインがあったりするのですが、微妙に使い勝手が悪く、個人的には理想的なものではないと感じています。

そこで graphql-codegen-typescript-fabbrica

もっと factory 関数を簡単に実装できるようにしたいと思い、graphql-codegen-typescript-fabbrica を作りました。

graphql-codegen-typescript-fabbrica は、factory 関数を定義するための utility を生成する GraphQL Code Generator プラグインです。これを使うと以下のような factory 関数を定義できます。

import { defineBookFactory, defineAuthorFactory, dynamic } from '../__generated__/fabbrica';
import { faker } from '@faker-js/faker';

const BookFactory = defineBookFactory({
  defaultFields: {
    __typename: 'Book',
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(() => faker.word.noun()),
    author: undefined,
  },
});
const AuthorFactory = defineAuthorFactory({
  defaultFields: {
    __typename: 'Author',
    id: dynamic(({ seq }) => `Author-${seq}`),
    name: dynamic(() => faker.person.firstName()),
    books: undefined,
  },
});

Factory の build() メソッドを呼び出すと、モックデータを生成できます。生成されるモックデータは、defaultFieldsbuild() メソッドに渡された field の値に応じて、厳密に型付けされます。

// simple
const book0 = await BookFactory.build();
expect(book0).toStrictEqual({
  __typename: 'Book',
  id: 'Book-0',
  title: expect.any(String),
  author: undefined,
});
expectTypeOf(book0).toEqualTypeOf<{
  __typename: 'Book';
  id: string;
  title: string;
  author: undefined;
}>();

// nested
const book1 = await BookFactory.build({
  author: await AuthorFactory.build(),
});
expect(book1).toStrictEqual({
  __typename: 'Book',
  id: 'Book-1',
  title: expect.any(String),
  author: {
    __typename: 'Author',
    id: 'Author-0',
    name: expect.any(String),
    books: undefined,
  },
});
expectTypeOf(book1).toEqualTypeOf<{
  __typename: 'Book';
  id: string;
  title: string;
  author: {
    __typename: 'Author';
    id: string;
    name: string;
    books: undefined;
  };
}>();

便利な機能一覧

factory 関数を片手間で自作するのでは実現が難しい、多数の便利な機能を提供しています。基本的には FactoryBot, prisma-fabbrica から輸入してきた機能です。

Sequences

seq パラメータを使うと、連番のモックデータを生成できます。id の生成に使うと便利です。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(async ({ seq }) => Promise.resolve(`ゆゆ式 ${seq}巻`)),
  },
});
expect(await BookFactory.build()).toStrictEqual({
  id: 'Book-0',
  title: 'ゆゆ式 0巻',
});
expect(await BookFactory.build()).toStrictEqual({
  id: 'Book-1',
  title: 'ゆゆ式 1巻',
});

Dependent Fields

get 関数を使うと、別の field に依存した値を生成できます。ある field の値から別の field の値を自動生成したい時に便利です。

const UserFactory = defineUserFactory({
  defaultFields: {
    name: 'yukari',
    email: dynamic(async ({ get }) => `${(await get('name')) ?? 'defaultName'}@yuyushiki.net`),
  },
});
expect(await UserFactory.build()).toStrictEqual({
  name: 'yukari',
  email: 'yukari@yuyushiki.net',
});
expect(await UserFactory.build({ name: 'yui' })).toStrictEqual({
  name: 'yui',
  email: 'yui@yuyushiki.net',
});

Building lists

buildList 関数を使うと、モックデータを N 個詰め込んだ配列を生成できます。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(({ seq }) => `ゆゆ式 ${seq}巻`),
  },
});
expect(await BookFactory.buildList(3)).toStrictEqual([
  { id: 'Book-0', title: 'ゆゆ式 0巻' },
  { id: 'Book-1', title: 'ゆゆ式 1巻' },
  { id: 'Book-2', title: 'ゆゆ式 2巻' },
]);

Associations (関連する type のモックデータの同時生成)

defaultFields には別の Factory を使ってモックデータを生成する関数を渡せます。これにより、関連する type のモックデータを同時に生成できます。セットで使うような type 同士の factory 関数を定義するのに便利です。

const BookFactory = defineBookFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Book-${seq}`),
    title: dynamic(({ seq }) => `ゆゆ式 ${seq}巻`),
    author: undefined,
  },
});
const AuthorFactory = defineAuthorFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Author-${seq}`),
    name: dynamic(({ seq }) => `${seq}上小又`),
    // 関連する Book type のモックデータを生成
    books: dynamic(async () => BookFactory.buildList(1)),
  },
});
expect(await AuthorFactory.build()).toStrictEqual({
  id: 'Author-0',
  name: '0上小又',
  books: [{ id: 'Book-0', title: 'ゆゆ式 0巻', author: undefined }],
});

Transient Fields

Transient Fields は factory 関数内でのみ利用可能な field を定義する機能です。build() の引数に渡す事はできますが、返り値には含まれない特殊な field です。get 関数と組み合わせると、結構面白いことができます。

Traits

Traits は field のデフォルト値をグループ化しておく機能です。TypeFactory.use('traitName').build() と書くと、そのデフォルト値を適用したモックデータが得られます。

よく画像を表す Image type などを GraphQL スキーマで定義することがあると思いますが、「ユーザアバターを生成する trait」をそれぞれ定義しておけば、ImageFactory.use('avatar').build() で簡単にユーザアバターのモックデータを生成できます。

import I_SPACER from '../assets/spacer.gif';
import I_AVATAR from '../assets/dummy/avatar.png';
import I_BANNER from '../assets/dummy/banner.png';

const ImageFactory = defineImageFactory({
  defaultFields: {
    id: dynamic(({ seq }) => `Image-${seq}`),
    url: I_SPACER.src,
    width: I_SPACER.width,
    height: I_SPACER.height,
  },
  traits: {
    avatar: {
      defaultFields: {
        url: I_AVATAR.src,
        width: I_AVATAR.width,
        height: I_AVATAR.height,
      },
    },
    banner: {
      defaultFields: {
        url: I_BANNER.src,
        width: I_BANNER.width,
        height: I_BANNER.height,
      },
    },
  },
});
expect(await ImageFactory.build()).toStrictEqual({
  id: 'Image-0',
  url: I_SPACER.src,
  width: I_SPACER.width,
  height: I_SPACER.height,
});
expect(await ImageFactory.use('avatar').build()).toStrictEqual({
  id: 'Image-1',
  url: I_AVATAR.src,
  width: I_AVATAR.width,
  height: I_AVATAR.height,
});
expect(await ImageFactory.use('banner').build()).toStrictEqual({
  id: 'Image-2',
  url: I_BANNER.src,
  width: I_BANNER.width,
  height: I_BANNER.height,
});

おわりに

ミニマムな API セットながらも、いくつもの強力な機能が実装された面白いライブラリなんじゃないかと思います。ぜひ使ってみてください。

*1:msw は Node.js、ブラウザ両方で任意のリクエストを interrupt できるライブラリです。Node.js 上では fetch などネットワークリクエスト API にモンキーパッチを当てて、ブラウザでは ServiceWorker を使ってリクエストに介入し、モックを実現してます。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.