GraphQL を使って Web アプリケーションを実装していると、GraphQL API のリクエストをモックしたいことがあると思います。
- ユニットテストのために、ダミーレスポンスに差し替えたい
- ビジュアルリグレッションテストのために、ダミーレスポンスに差し替えたい
- Storybook で story を書くために、ダミーレスポンスに差し替えたい
- バックエンドの resolver 実装を待たずにフロントエンド側の開発を始めるために、ダミーレスポンスに差し替えたい
一般には GraphQL Client にモックするための機能が実装されてるので、そうしたものを使うことが多いと思います。
また最近は Client よりも外側のレイヤーでリクエストを interrupt してモックする「msw」を使うケースも増えてきてます *1。
モックデータ作成に手間がかかる
モックする時によく煩わしく感じるのが、モックデータの作成です。例えばビジュアルリグレッションテストのために 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()
メソッドを呼び出すと、モックデータを生成できます。生成されるモックデータは、defaultFields
や build()
メソッドに渡された 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 を使ってリクエストに介入し、モックを実現してます。