🏆

mswとgraphql codegenでGraphQLをモックし、効果的で効率的なReactのテストを書く

2021/08/31に公開
4

はじめに:テスト戦略の話

そもそもフロントエンドのテストって何をどんな粒度で書けばいいの?という疑問はよくあると思います。これについては以前Qiitaの記事: フロントエンドでTDDを実践する(理論編)で書いているので詳細は省きますが、Testing Trophyの戦略に則り、integration testを中心とし、unitテストは本当に有効なものに絞って書くというのがフロントエンドにおいては有効です。

APIモック

フロントエンドのintegration testにおいて課題になるのがAPIのモックです。urqlやapollo clientのようなGraphql Clientでもqueryをモックする仕組みはありますが、どちらもclient自体をモックするような書き方になります。一見問題ないようですが、client側でキャッシュの挙動を制御したりできることもあり、本番コードと異なるコードをテストすることにつながってしまいます。これはなるべくモックを少なくするべきというテストのプラクティスに反しているので効果的と言えず、加えてテストコードが利用しているgraphql clientライブラリに依存してしまいます。

また、APIのモックデータ定義も網羅的なテストを書いていくにあたっては課題となりやすいです。データが複雑になればなるほどモックデータ定義のメンテコストも高まります。

そこで本記事では比較的新しいモックライブラリであるmsw、そしてスキーマ等をもとにコードを生成してくれるgraphql-codegenを使って、効果的かつ効率的なGraphQLレスポンスをモックしたテストの書き方について紹介します。

前提

  • 記事の冗長化を防ぐため、基本的な解説はしません。graphql-codegenを使ったgraphqlアプリの開発や、react-testing-libraryを使ったコンポーネントのテストの基礎を前提としています
  • サンプルはNext.jsですが、普通のReactでも同じようにテスト可能です
  • アプリケーションコードについても冗長化を防ぐため記載せず、テストコードにフォーカスします。 コードの全容は リポジトリを参照ください

各種セットアップ

  • graphql-codegen
  • msw
  • jest (testing-library)

のセットアップをしていきます。

graphql-codegenのセットアップ

codegen.yml

overwrite: true
schema: "<schemaへのパス>"
documents:
  - './src/**/!(*.d).{ts,tsx}'
  - './src/**/*.graphql'
  - '!src/generated/**/*'
generates:
  src/generated/graphql.ts:
    preset: gql-tag-operations-preset
    plugins:
      - '@homebound/graphql-typescript-factories'

ポイントは2つ、gql-tag-operations-presetとgraphql-typescript-factoriesです。それぞれ見ていきましょう。

gql-tag-operations-presetでtyped document nodeを生成する

gql-tag-operations-presetは、code-genをwatchモードで起動しておけばファイル内で定義したgqlがそのままTyped Docment Nodeとして使えるようになるpresetです。codegenでの型生成をわざわざ叩いたりimportする必要がなくなるので便利です。(テストの観点ではTyped Document Nodeがほしいだけなので、従来のtyped-document-nodeのプラグインでも構いません)

import { useQuery } from 'urql'
import { gql } from '@app/gql'

const GetTodos = gql(/* GraphQL */ `
  query GetTodos {
    todos {
      id
      createdAt
      updatedAt
      title
      content
    }
  }
`)

function Todos() {
  const [res] = useQuery({ query: GetTodos })
  // 以下略
}

@homebound/graphql-typescript-factoriesでダミーデータFactory関数を生成する

モックデータ定義、面倒ですよね。実際のテストを書いていくと、各テストケースで意識するべきフィールドは一部なのに他のフィールドを定義するのは冗長です。そのためにモック用DSLやclassを自作したりというのはよくあると思います。@homebound/graphql-typescript-factoriesは、スキーマを元にそのようなモックデータ生成をするFactory関数(例:newUser, newTodo)を生成してくれる便利なやつです。

import { newTodo } from 'src/generated/graphql.ts/graphql'

const todoData = newTodo()
const todoDataCompleted = newTodo({completed: true})

mswのセットアップ

mswはブラウザ、Node環境でRest/Graphqlのリクエストをモックしてくれるライブラリです。

handler, serverを定義する

handlerに、基本となるモックデータ定義をします。どのテストでも基本となるqueryのレスポンスパターンを書いていきます。mutationを書く必要は基本的にないでしょう。

mock/handler.ts

import { graphql } from 'msw'
import { GetTodosDocument, newTodo } from 'src/generated/graphql'

export const handlers = [
  graphql.query(GetTodosDocument, (req, res, ctx) =>
    res(
      ctx.data({
        todos: [newTodo(), newTodo()],
      })
    )
  ),
]

mock/server.ts

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

jestのセットアップ

特筆することはありませんが、mswのサーバーの設定をjestのテストに反映させるテンプレコードです。

jest.setup.ts

import 'isomorphic-unfetch' // next.js用
import '@testing-library/jest-dom/extend-expect'

import { server } from 'src/mocks/server'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

テスト用render関数定義

テスト用のrender関数を定義します。Graphql Client Providerのセットアップ等を行います。下記サンプルコードでは、AppProviderにgraphql clientのProviderを含むイメージです。

import React from 'react'
import { render } from '@testing-library/react'
import { GraphQLHandler, GraphQLRequest } from 'msw'
import { AppProvider } from 'src/components/util/AppProvider'
import { server } from 'src/mocks/server'

export const testRenderer =
  (children: React.ReactNode) =>
  (responseOverride?: GraphQLHandler<GraphQLRequest<never>>) => {
    if (responseOverride) {
      server.use(responseOverride)
    }
    render(<AppProvider>{children}</AppProvider>)
  }

responseOverrideは、mswのhandlerで定義した基本となるレスポンスをテストケース毎にoverrideできるようにしています。

実際のテストを書く

実際のテストを書いていきます。インテグレーションテストとしては、アプリケーションの規模が大きくなければ、pages単位で書いていくのをおすすめします。next.jsでpages/todos/index.page.tsxがあった時、pages/todos/index.test.tsxを置くイメージで

基本ケース

import { screen } from '@testing-library/react'
import Todos from './index.page'
import { testRenderer } from 'src/utils/test-util'

describe('Todos Page', () => {
  const renderPage = testRenderer(<Todos />)

  it('フェッチしたtodoを表示する', async () => {
    renderPage()
    const target = await screen.findAllByTestId('todo')
    expect(target.length).toBe(2)
  })
)}

基本となるケースでは、mswのhandlerに定義したresponseを前提とします。ここでは、「フェッチしたデータをその件数分表示していること」をテストしています。"表示する"ことのテストはチームにより定義が異なるのでニーズに合ったassertionをすると良いと思います。

エッジケース

handlerで定義した基本パターンではないレスポンスパターンでのテストをしたい場合です。0件時、エラー時や、データパターンにより異なる表示仕様、などが相当します。

import { screen, within } from '@testing-library/react'
import { graphql } from 'msw'
import { GetTodosDocument, newTodo } from 'src/generated/graphql.ts/graphql'

describe('Todos Page', () => {
  const renderPage = testRenderer(<Todos />)

  it('todoがcompletedの場合は「完了」ラベルが表示されている', async () => {
    renderPage(
      graphql.query(GetTodosDocument, (req, res, ctx) => 
        res.once(
	  ctx.data({
	    todos: [newTodo({completed: true})]
	  })
        )
      )
    )
    const target = await screen.findByTestId('todo')
    expect(within(target).getByText('完了')).toBeInTheDocument()
  })
)}

renderPage関数にmswのモック定義を渡してあげれば、既存のモック定義を上書きすることができます。なお、上書きで定義していないqueryのモック定義は残ります。

mutationのテスト

mutationのテストも、msw層で実現可能です。

import { screen, within } from '@testing-library/react'
import { graphql } from 'msw'
import { GetTodosDocument, newTodo } from 'src/generated/graphql.ts/graphql'

describe('Todos Page', () => {
  const renderPage = testRenderer(<Todos />)

  it('タイトルを入力し「新規作成」ボタンを押すと新規投稿できる', async () => {
    const mutationInterceptor = jest.fn()
    renderPage(
      graphql.mutation(SaveTodoDocument, (req, res, ctx) => {
        mutationInterceptor(req.variables)
        return res.once(
          ctx.data({
            saveTodo: {
              __typename: 'Todo',
              id: '1',
            },
          })
        )
      })
    )
    // act
    const input = screen.getByLabelText('title')
    fireEvent.change(input, { target: { value: 'test' } })
    const submitButton = screen.getByText('Submit')
    fireEvent.click(submitButton)
    // assert
    await waitFor(() =>
      expect(mutationInterceptor).toHaveBeenCalledWith({
        todo: {
          title: 'test',
        },
      } as SaveTodoMutationVariables)
    )
  })
)}

const mutationInterceptor = jest.fn()でモック関数を定義し、mswのoverride時にreq.variableを渡してあげます。

もし正しくmutationが呼び出されれば、このモック関数も正しいvariableを引数として呼び出されるので、それをassertしてあげれば正しくテストすることができます。

まとめ

  • mswを利用することでmockをなるべく少なくして本番に近いコードをテストすることが可能
  • 面倒なモック定義はgraphql-codegenの@homebound/graphql-typescript-factoriesをう使うと便利

アプリケーションコードを含めたコードは下記リポジトリに置いてあります
https://github.com/taneba/fullstack-graphql-app/tree/main/apps/frontend

Discussion

nayakayanayakaya

apolloのテストについて参考になりました!
ありがとうございます!

Ryosuke MiyamotoRyosuke Miyamoto

参考になりました。記事書いてくださり、ありがとうございます!

Ryosuke MiyamotoRyosuke Miyamoto

最後レポジトリリンクが切れていたので共有だけしておきます。(ディレクトリ構造が変わっっている?)

yoshihiro nakamurayoshihiro nakamura

ありがとうございます、turborepo導入でディレクトリ構成変わったのを忘れていました、修正しました!