newmoではフロントエンドとバックエンドの通信をGraphQLで行っています。GraphQLのスキーマは、フロントエンドとバックエンドが合意した唯一の正しい定義、いわば「正となる単一の情報源(Single Source of Truth)」です。このスキーマを正として、開発と自動テストの両方をここから組み立てたい。その基盤として@newmo/graphql-fake-serverを自作してOSSとして公開しています。
このライブラリは、スキーマを正としたまま、2つの使い方を1つのサーバで両立します。1つはスキーマに@example* directiveを書くだけで値が返るDeclarative Fake、もう1つはHTTP経由で動的にレスポンスを登録できるDynamic Fakeです。どちらの値もスキーマから派生するため、スキーマと矛盾するFakeは作れません。newmo-appではこのFake Serverを使ったPlaywrightのIntegration Testが数百ページ分書かれています。
この記事では、なぜスキーマを正とする発想からStubではなくFake Serverに行き着いたのか、既存ツールがある中でなぜ自作したのかを解説します。あわせて、実際にどう動いていて、newmoの中でどう運用しているのかも順に見ていきます。
スキーマを正とする
出発点は、GraphQLのスキーマを正とすることです。default値やテストで登録するレスポンス、その検証やlintまで、すべてスキーマから派生させたい。この発想でテスト戦略を組むと、テストで本物の代わりに使うもの(Test Double)の選び方も決まってきます。
Googleが公開しているSoftware Engineering at Googleの第13章では、Test DoubleをStub・Mock・Fakeの3種類に分けています。Stubは用意した応答を返すだけのもの、Mockはそれに加えて「どう呼ばれたか」を検証するもの、Fakeは本物に近い振る舞いをする軽量実装(章中ではin-memory databaseが例)です。
同じ章の要約(13.10)では、運用上の優先順位が次のように示されています。
テストダブルより本物の実装が優先されるべきである。テスト内で本物の実装が利用できないなら、フェイクが理想的な解法である場合が多い。スタビングを使いすぎると、不明確で脆いテストにつながる。インタラクションテストは、できるだけ避けるべきである。
-- Googleのソフトウェアエンジニアリング - O'Reilly Japan
スキーマを正としたいなら、この優先順位の中で選ぶべきはFakeです。スキーマから派生したdefaultやレスポンスを返すには、スキーマを実際に実行する軽量な実装が要ります。一方、リクエストを横取りして用意したモックデータを返すだけの仕組みは、Test Doubleの分類ではStubに当たります。フロントエンドでよく使われるMSWや、Playwrightのpage.routeがこのStubです。Stubはスキーマを経由せずに応答を組み立てるため、スキーマと矛盾する嘘の応答も書けてしまいます。スキーマを正とするなら、選ぶのはStubではなくFakeになります。
newmoのフロントエンドのテストガイドラインでもこの順序を採用しています。Unit TestとIntegration TestではStubを使わず、引数に本物のオブジェクトを渡せる設計にすることを基本にしています。
newmoのフロントエンドにおけるテスト戦略
newmoフロントエンドのテストは3層で構成しています。
| 種類 | ツール | 通信 | 用途 |
|---|---|---|---|
| Unit Test | Vitest | なし | 複雑なロジック、条件分岐の多い関数 |
| Integration Test | Playwright | Fake Server | ページとしての動作、操作フロー |
| E2E Test | Playwright | 本物のAPI | 落ちたらユーザーが何もできなくなる最重要フロー |
複雑なロジックはUnit Testで書けるようPureなJavaScriptに分離し(Domain Logic Separation)、ReactはPresentation Layerとして扱います。Integration Testの通信先はFake Server、本物のAPI通信はE2Eだけで行います。
E2Eは「落ちたらユーザーが何もできなくなる最重要フロー」だけを対象にして、量を絞っています。
テスト量の大半はIntegration Test、つまりPlaywright + Fake Serverで書かれています。この層が安定しないとテスト戦略全体が成り立たないため、Fake Serverを安定運用できる基盤の用意が必要でした。
既存ツールでは両立できなかったので自作した
スキーマを正とする前提に立つと、必要なのはDeclarative FakeとDynamic Fakeの両立でした。Declarative Fakeは、スキーマに「この型のこのフィールドはデフォルトでこの値を返す」と書ける仕組みで、UI開発とGraphQL Playgroundでの疎通確認に使います。Dynamic Fakeは、テストごとにHTTPで「このリクエストにはこのレスポンスを返す」と動的に切り替えられる仕組みで、Integration Testに使います。どちらもスキーマから派生する層なので、両方の機能が必要でした。
GraphQLのFakeを実現するライブラリ自体は、Proposalを書いた2024年4月時点でもいくつか存在していました。主なものは次の3つで、それぞれスキーマからの派生が片側に寄っていました。
- mizdra/graphql-codegen-typescript-fabbrica: GraphQLの型から型安全なfake factoryを生成するcodegenプラグイン。関心はコード上で型安全にFakeオブジェクトを組み立てることにあり、HTTPサーバではない
- APIs-guru/graphql-faker: スキーマにdirectiveを書いてランダム値を返すモックサーバ。静的中心で、テストごとに動的にレスポンスを切り替える仕組みは弱い
- wayfair-incubator/gqmock: HTTPで動的にモックを上書きできるApollo Serverベースのモックサーバ。一方、スキーマ駆動による静的記述の層は弱い
3つの先行ツールはどれもDeclarativeかDynamicのどちらかに寄っており、スキーマを起点に両方を派生させる形にはなっていませんでした。加えてnewmoの場合は将来的にiOSやAndroidからも使う可能性があり、HTTP-firstで言語非依存にしたいという要件もありました。これらをすべて満たすライブラリは2024年4月時点で存在しなかったため、自作する判断になりました。
初期実装はmizdraさんのfabbricaのアプローチを参考にしました。READMEのCreditsにはfabbrica、graphql-faker、gqmockを載せています。
なお、2026年6月には、directiveでモックデータを指定する@mock(GAP-10)が、GraphQL Auxiliary Proposals(GAPs)の1つとして提案されています。directiveでモックデータを書く点は、Declarative Fakeと近い発想です。ただし@mockはoperation(クライアントのクエリ)側にdirectiveを書き、応答をクライアントでマージする方式です。スキーマにないfieldも意図的にモックできるため、スキーマを正としてサーバ側でスキーマを実行するgraphql-fake-serverとは設計の軸が異なります。
Fakeのオーナー不在問題
もう1つ、Proposalの段階で意識していたのがFakeのモックデータのオーナー不在問題です。
ページや機能ごとに場当たり的に偽の応答を書き散らしていくと、誰がそれのOwnerなのかが不明確になり、本物のサーバとの間に齟齬が生じやすくなります。個別に作った偽物はメンテナンスのコストがかかるため、結局メンテされなくなり、嘘の応答を返したまま残るというのが典型的な失敗パターンです。
graphql-fake-serverでは、これに対する答えとして2つの方向でスキーマ駆動を貫きました。1つ目はDeclarative Fakeをスキーマ側に置く構造です。@exampleString(value: "...")のようなdirectiveはスキーマファイルに書くため、スキーマを変えるPRの中で同じレビューを通ります。2つ目はDynamic Fake側の仕組みです。後述のCode Generatorが型付きfake clientを生成するため、スキーマを変更するとfake clientも再生成されます。スキーマと非互換なFakeが残ればIntegration Testがコンパイルや実行の段階で落ちます。「スキーマのOwner = FakeのOwner」が抽象的なルールではなく、テストのfailureとして実装側から強制される構造になっています。
graphql-fake-serverの仕組み
ここからはgraphql-fake-serverの仕組みを4つに分けて解説します。Declarative Fake、Dynamic Fake、/fake/called、ESLint Pluginの順です。
Declarative Fake: スキーマで静的に書く
Declarative Fakeは、スキーマに@example*系のdirectiveを書くだけで使えます。本記事ではBookとAuthorを題材に最小例で解説します。
# schema.graphql type Query { books: [Book!]! } type Mutation { addBook(input: AddBookInput!): AddBookPayload! } input AddBookInput { title: String! authorId: ID! } type AddBookPayload { book: Book errors: [Error!]! @error } type Book { id: ID! @exampleID(value: "book-id") title: String! @exampleString(value: "The Great Gatsby") author: Author! errors: [Error!]! @error } type Author { id: ID! @exampleID(value: "author-id") name: String! @exampleString(value: "F. Scott Fitzgerald") } type Error { message: String! }
このスキーマを用意してnpx @newmo/graphql-fake-server --schema schema.graphqlで起動すると、Fake Serverが立ち上がります。
http://127.0.0.1:4000/queryにGraphQLクエリを投げると、@example* directiveに書いた値が返ってきます。たとえば次のクエリを投げます。
query { books { id title author { id name } } }
結果は次のようになります(listはデフォルトで3件返ります)。
{ "data": { "books": [ { "id": "book-id_g0_c0", "title": "The Great Gatsby", "author": { "id": "author-id_g1_c0", "name": "F. Scott Fitzgerald" } }, { "id": "book-id_g2_c1", "title": "The Great Gatsby", "author": { "id": "author-id_g3_c1", "name": "F. Scott Fitzgerald" } }, { "id": "book-id_g4_c2", "title": "The Great Gatsby", "author": { "id": "author-id_g5_c2", "name": "F. Scott Fitzgerald" } } ] } }
GraphQL Playgroundも同時に起動するため、フロントエンドエンジニアはPlaygroundでスキーマと値を確認しながらUIを組めます。バックエンドの実装を待たずにUI開発を始められるのが、この層の目的です。
ID値は@exampleID(value: "book-id")のように指定すると、book-id_g${global_id}_c${count}の形で生成されます。globalにユニークかつnameごとのカウントが見えるため、スナップショットやUIの表示順が安定します。
@errorは、ユニオン的に扱うGraphQLのエラー型を空配列で初期化するためのマーカーです。GraphQLのエラー設計と相性を取るためにライブラリ側で用意してあります。カスタムスカラ(DateやTimeなど)には@exampleScalar* directiveを使うか、fake-server.config.mjsで型ごとの既定値を書きます。
Dynamic Fake: HTTPで動的に登録する
Integration Testではテストごとに異なるレスポンスが必要になります。これを担当するのがDynamic Fakeで、登録はHTTPのPOST /fakeで行います。レスポンスを登録して返すという意味で、この層自体はTest Doubleの分類ではstubbingに当たります。Declarative Fakeと違うのは、後述の型付きfake clientでこのstubをスキーマに縛る点です。
HTTPで直接叩くと次のようになります。任意の言語から使えるという意味では、これがFake Serverの最小インターフェースです。
const sequenceId = crypto.randomUUID(); await fetch("http://127.0.0.1:4000/fake", { method: "POST", headers: { "Content-Type": "application/json", "sequence-id": sequenceId, }, body: JSON.stringify({ type: "operation", operationName: "GetBooks", data: { books: [{ id: "book-1", title: "The Great Gatsby" }], }, }), });
ただしこのまま使うとdataは任意のJSONでしかありません。スキーマにないfieldを書いても、titleをtitelと打ち間違えても、サーバ側は受け付けてしまいます。これだと「Fakeで嘘をつけてしまう」状態になり、検証側まで含めてスキーマで縛りたいという当初の方針から外れます。
そこでTypeScriptの運用では、@newmo/graphql-codegen-fake-server-clientというGraphQL Code Generatorのプラグインを併用します。これを挿すと、OperationごとにregisterXxxQueryResponseやregisterXxxMutationResponseを持つ型付きfake clientが自動生成されます。同じ登録を型付きfake client経由で書くと、次のようになります。
import { createFakeClient } from "./generated/fake-client"; const fakeClient = createFakeClient({ fakeServerEndpoint: "http://127.0.0.1:4000", }); const sequenceId = crypto.randomUUID(); await fakeClient.registerGetBooksQueryResponse(sequenceId, { books: [ { id: "book-1", title: "The Great Gatsby", author: { id: "author-1", name: "F. Scott Fitzgerald" }, errors: [], }, ], });
第二引数の型はスキーマと整合した状態で生成されるため、スキーマにないfieldを登録しようとしたり、必須の__typenameを忘れたりするとコンパイルが通りません。Fakeの値もスキーマで縛られた状態のままテストに乗ります。newmo-appでは基本的にこちらの形で書いています。HTTPは他言語からの叩き口、型付きfake clientは型安全な日常運用、という二段構成になっています。
重要なのはsequence-idヘッダーで、型付きfake client経由でも自動的に乗ります。Fake Serverは(sequence-id, operationName)のペアでレスポンスをkeyにしています。テストごとにcrypto.randomUUID()で発行したsequence-idを使えば、別のテストが登録したFakeを踏まずに済むため、Playwrightのテストを並列に実行できます。
variablesの値に応じて複数のレスポンスを切り替えたいケース(adminと一般ユーザーで違う応答を返したい場合など)には、Conditional Fakeを使います。登録時にrequestConditionでtype: "variables"(完全一致)またはtype: "always"(フォールバック)を指定します。複数登録した場合は具体度の高い条件を優先するため、テスト側はそのテストで意味のある分岐だけ書けば済みます。
/fake/called: 受け取ったリクエストを検証する
POST /fake/calledを叩くと、同じ(sequence-id, operationName)で流れたリクエスト履歴を取り出せます。Fakeが受け取ったリクエストを検証するためのAPIです。
専用APIを用意した理由は、検証側のテストコードがスキーマから乖離しないようにするためです。
Dynamic Fakeで導入した型付きfake clientは、calledXxxQueryとcalledXxxMutationという履歴取得関数も同じスキーマから生成します。これらを経由して履歴を取り出すと、スキーマと整合したvariablesの型で取得できます。スキーマにないfieldをassertに書くとコンパイルが通りません。
実際のテストは次のように書きます。
// books.play.ts import { expect, graphqlTest } from "./test-setup"; import { fakeClient } from "./generated/fake-client"; import { createUrlWithSequenceId } from "./test-utils"; graphqlTest("本一覧を表示して、新しい本を追加できる", async ({ page, sequenceId }) => { // 1) このテスト用に、本一覧クエリのFakeレスポンスを登録する await fakeClient.registerBooksQueryResponse(sequenceId, { books: [ { id: "book-1", title: "The Great Gatsby", author: { id: "author-1", name: "F. Scott Fitzgerald" }, errors: [], }, ], }); // 2) 追加mutationのFakeレスポンスを登録する await fakeClient.registerAddBookMutationResponse(sequenceId, { addBook: { book: { id: "book-2", title: "新しい本", author: { id: "author-1", name: "F. Scott Fitzgerald" }, errors: [], }, errors: [], }, }); // 3) ページにアクセス(sequenceIdはURL経由でアプリに渡し、リクエストヘッダーに伝播させる) await page.goto(createUrlWithSequenceId("/books", { sequenceId })); // 4) 一覧の表示確認 await expect( page.getByRole("listitem").filter({ hasText: "The Great Gatsby" }), ).toBeVisible(); // 5) 追加フォームを操作 await page.getByLabel("タイトル").fill("新しい本"); await page.getByRole("button", { name: "追加" }).click(); // 6) UI上で「追加が完了した」ことを先に待つ // - ボタンを押した直後に /fake/called を叩くと、mutationがサーバに届く前に // レースして空配列を読んでしまう可能性がある // - レスポンスが反映されたあとに必ず現れるUI(Toastなど)を起点にauto-retryで待つ await expect( page.getByRole("status").filter({ hasText: "本を追加しました" }), ).toBeVisible(); // 7) UIに結果が反映された後で、Fake Serverに流れたmutationを // スキーマで型付けされたcalledAddBookMutationで取り出して検証する const called = await fakeClient.calledAddBookMutation(sequenceId); expect(called.data[0]?.request.body.variables).toMatchObject({ input: { title: "新しい本", authorId: "author-1" }, }); });
このテストには2つのポイントがあります。
1つ目は6)のUIで待ってから検証する順序です。ボタンクリックの直後に/fake/calledを叩いてしまうと、mutationがFake Serverへ届く前にチェックが走ります。その結果、履歴が空のままアサーションが落ちるFlakyなテストになります。Playwrightのexpect().toBeVisible()は要素が現れるまでauto-retryします。そのため、Toastのようなレスポンス反映後にしか現れないUIを起点にすれば、副作用(mutation送信)発生後であることを担保できます。
これはgraphql-fake-server固有の話ではなくPlaywrightの作法ですが、/fake/calledの使い方とセットで誤解されやすいため明示しておきます。
2つ目は7)の検証側の型付けです。fakeClient.calledAddBookMutationの戻り値はスキーマと整合した型になっているため、input.titleをinput.titelと打ち間違えればコンパイルが通りません。スキーマにないfieldをassertに書くこともできません。
これをPlaywrightのpage.routeで書くと扱いが異なります。page.routeはHTTPリクエストをhookするだけのStubであって、Fakeではありません。リクエストを任意のJSONとして扱うため、スキーマと矛盾する期待値を書けてしまいます。本物 > Fake > Stub/Mockの序列を検証側にも適用したい場合、page.routeベースの検証では型安全性が劣ります。
一方で/fake/calledは、Fakeが返したレスポンスとFakeが受け取ったリクエストの両方を、スキーマで縛られた状態のままテストで扱える仕組みです。
さらに、Playwright固有APIに依存しないため、iOSやAndroidからもHTTPで同じ検証ができます。そもそも言語を選ばないようHTTPベースにしたので、これは副次効果ではなく当初からの目的の1つです。
ESLint Pluginで落とし穴を埋める
スキーマ規模が大きくなるほど、directiveの付け忘れのような落とし穴が無視できなくなります。これに対しては@newmo/eslint-plugin-graphql-fakeを提供しています。
現状あるルールはrequired-error-directiveの1つで、errorsという名前のフィールドに@error directiveを付けていないとエラーにします。このルールにより、エラー設計のミスをCIで先に検出できます。
使い始める
実際に試してみたい場合の最短手順は次の通りです。詳細はnewmo-oss/graphql-fake-serverのREADMEを参照してください。
- パッケージをインストールする:
npm install --save-dev @newmo/graphql-fake-server - スキーマに
@example*directiveを書く(Declarative Fake) - CLIで起動する:
npx @newmo/graphql-fake-server --schema schema.graphql - 型付きfake clientの利用は、GraphQL Code Generatorの設定に
@newmo/graphql-codegen-fake-server-clientを追加する - ESLintのチェックを追加する場合は、
@newmo/eslint-plugin-graphql-fakeを導入する - Dockerで起動する場合は、
ghcr.io/newmo-oss/graphql-fake-serverイメージを利用する
手順4を追加すると、Dynamic Fakeや/fake/calledの型付きAPIが利用できます。
設定オプション、Conditional Fakeの細かな仕様、カスタムスカラ向けのfake-server.config.mjsの書き方などは、リポジトリのREADMEにまとめています。
newmoでの運用
newmo-appでは1ページにつき最低でも1つのPlaywrightテストケースを書く方針です。そのため、ここまでの仕組みは数千件規模テストケースのIntegration Testで利用しています。
開発時の起動はpnpm run devコマンドで完結します。dev:nextでNext.jsを立てるのと並行してdev:apiでgraphql-fake-serverを立てる構成にしてあり、フロントエンドは自動的にFake Serverへ接続されます。Fake ServerとPlaygroundは決まったポートで起動するため、Playgroundでスキーマを確認しながらUIを作る流れをそのままテストに引き継げます。
接続先はアプリのenvで切り替えられるようにしてあります。本物のAPIを相手に動かしたい場合は、envを差し替えるだけで済み、アプリのコードにテスト用のif分岐を入れる必要はありません。前述のnewmoのフロントエンドのテストガイドラインで言うところの「アプリケーションは設定で本物と偽物のサーバに接続先を変更できるだけでテストができる状態にする」が、実装で成り立っている状態です。
なお、テストがこれだけ増えると、PlaywrightのCI実行時間が無視できなくなります。shardingや分割、キャッシュなどでCI側を並列化する工夫が別途必要になりますが、これは別の話題です。newmoでの取り組みは、PlaywrightのテストをDocker ImageとProjects機能で安定化と高速化などで公開しています。
Web以外(モバイル)からの利用
HTTP-firstなので、設計としてはiOSやAndroidからも同じFake Serverを叩けます。ただし型付きfake clientはTypeScript向けにしか提供していないため、他言語はHTTP直叩きか自前のヘルパを書く必要があります。Android側で活用調査をしたIssueの結論は次の通りです。デフォルトの正常系レスポンスを返すだけなら初期設定のみで動作する。一方、独自レスポンスを登録する場合は追加の開発が必要。この理由から、現状は本格運用には至っていません。
言語非依存のHTTP APIのため、将来モバイル側で同じFakeを共有する余地は残しています。
運用してわかったこと
2年弱運用していると、最初は予想していなかった問題にもいくつか直面しました。主なものを以下に整理します。
大きなスキーマでの生成コスト
スキーマが大きくなると、生成するモックデータもそれに比例して大きくなります。newmoの運行管理サービスのスキーマは約250KB / 8000行以上に達しました。そのため、ある時点でJSON.stringifyの文字列長制限(約512MB)を超え、RangeError: Invalid string lengthが出るようになりました。deep cloneを再帰実装に置換し、その後モック生成自体をlazyにすることで解消しました。
型から生成するものを運用する場合は、どこで生成コードが爆発するかを早めに見積もるか、最初からlazyにしておくのが望ましいという学びになりました。
ローカルサーバでも安全側のデフォルトを持つ
ローカルサーバの安全側のデフォルトも軽視できません。開発時に立つlocalhostのサーバでも、CORSを*にしているとブラウザ経由で外部から叩けてしまう、DNS rebindingで偽のHostヘッダーを使われるといった実害があります。CORSを明示許可制にし、Hostヘッダー検証を追加しました。テスト用のローカルサーバだから何でもいい、という前提は通用しません。
複雑な機能は入れない
複雑な機能は入れない、というのも2年運用して実感した点です。似た目的の機能が複数あると、利用者はどれを使うか迷い、メンテナンス側は重複した概念を抱え続けます。実際に、別々に用意していた機能を1つの概念に統合し、重複するほうを破壊的変更で削除しました。機能を増やすよりも、概念を1つに絞るほうが、長期メンテナンスの効率に寄与しました。
やらないことを明示する
仕組みを増やす一方で、やらないことも明示してきました。Fake Server自体にrequestバリデーション機能は入れていません。スキーマ準拠かどうかはGraphQLライブラリに任せる、検証ロジックをHTTP経由でいじれるようにはしないという線を引いています。スキーマと現実のサーバとの整合チェックもFakeの責務にはしていません。これはスキーマレジストリやCIの責務だからです。
やらないことを書いておくと、機能追加するときに対象範囲の判断軸として使えます。範囲を狭く保ったまま長く運用できているのは、Design Docの段階で目的ではないことを明示し続けた結果と言えます。
AIコーディングエージェント普及前にテスト基盤を整備できていた
newmoのフロントエンドのテストは、スキーマを正としたIntegration Test(Playwright + Fake)を基本にしています。この体制をAIコーディングエージェントが普及する前に用意できていたことが、後から効いてきました。理由を次に説明します。
一般論として、AI AgentはVitestのようなMocking機能があると、それを使ってテストを書く傾向にあります。stubbingを多用してスキーマと一致しないモックデータを書けば、嘘をついたテストや壊れやすいテストが増えます。スキーマや実装と乖離した期待値を、テストコード上で簡単に書けてしまうためです。
newmoの場合はgraphql-fake-server、型付きfake client、ESLint pluginにより、スキーマと一致しないモックデータを書く動機を実装側でほぼ消してありました。そのため、AI Agentがコードを書く時代になっても、Integration Test(Playwright + Fake)に寄せる方針を保てました。規律をガイドラインだけで守っていたら、stubbingの多用へ流れていた可能性があります。同じ規律を人とエージェントの両方に効かせるには、文章ではなくツールで支える必要がある、というのが教訓です。
まとめ
newmoでは、GraphQLのフロントエンド開発とIntegration Testの基盤として@newmo/graphql-fake-serverを自作し、OSSとして公開しています。スキーマで宣言するDeclarative Fakeと、HTTPで動的に切り替えるDynamic Fakeを1つのサーバで両立し、/fake/calledで検証側にもスキーマ駆動を持ち込んでいます。
2年運用した結果、フロントエンドのテストはスキーマを正としたIntegration Test(Playwright + Fake)がベースになりました。スキーマに縛られているぶん、矛盾する嘘の期待値を書きにくい状態を維持できています。この状態をAIコーディングエージェントの普及前に達成できたことで、スキーマを無視したstubbingの多用へ流れるのを防げました。規律を方針として記述するだけでなく、ツールで支える価値はここにあります。
似た要件のチームは、@newmo/graphql-fake-serverを直接活用できます。詳細は@newmo/graphql-fake-serverのリポジトリを参照してください。
newmoではエンジニアを積極的に採用中です。興味がある方は、キャリアサイト https://careers.newmo.me/ をご覧ください。