こんにちは。フロントエンド/SETエンジニアの@1010realです。
子育て中は自分の事が全て後回しになりますね。。 # 今日の朝ごはんはカップ麺
今回は、新機能の開発において、mock service workerを使ってGraphQLエンドポイントをモックした単体テストを導入したので、それについてまとめてみました。
ちなみに導入に関しては、大部分を以下の記事を参考にさせて頂きました。とても勉強になりました。
mswとgraphql codegenでGraphQLをモックし、効果的で効率的なReactのテストを書く
本記事は上記の導入に際して、細部の環境の差異、実際につまづいた点および自分が調べた点を追加した内容になっております。
msw(=mock service worker)はその名の通りサービスワーカーを使ったAPIモックサーバーです。 サービスワーカーレベルでAPIリクエストをインターセプトしてモックデータをレスポンスすることができます。 上記のメリットとして、APIをコールするクライアント側の実装は全く変更することなく、モックによる開発を行うことができます。
また、Nodejs環境でも起動方法を変更する事で、そのままモックサーバとして利用できるため、単体テストにも同じモック定義を利用できるというメリットがあります。
従来のjestの機能を用いたモックではhooksかapi clientそのものをモック化する必要があり、これらを利用したテストにおいては、当然モック部分の実装の担保はできませんでしたが、mswはそれを解決する一つのソリューションになり得ると思います。
以下のような開発環境に対しての導入手順を示します。
typed-document-nodeプラグインを用いて、code-gen時にtyped document nodeを生成するように変更します。
npm install @graphql-codegen/typed-document-node
codegen.yml
overwrite: true
schema: 'GraphQLエンドポイント'
documents:
- 'src/**/*.graphql
generates:
src/graphql/index.ts:
plugins:
- 'typescript'
- 'typescript-operations' # クエリから型を生成
- 'typed-document-node' # クエリからTypedDocumentNodeを生成 <= 追加
ちなみに、2022/03/2現在、typed-document-nodeとtypescript-react-apolloとの共存はできない様です。 こちらのissueに回避策が提示されてはいますが、個人的にはhooksを使用しているところをuseQueryを使って書き直す方が得策かと思います。
// use hooks
const { data, loading, error } = useXyzQuery({
// useQuery + typed document node
const { data, loading, error } = useQuery(XyzDocument, {
mswをインストールし、mock化したいエンドポイントに対するHandlerをTypedDocumentNode毎に定義します。
npm install msw --save-dev
src/mocks/handler.ts
import { graphql } from 'msw'
import { XyzDocument } from 'src/graphql'
const xyzDefaultResponse = {
__typename: 'Xyz',
x: 0,
y: 90,
z: 0
}
export const handlers = [
// @ts-ignore
graphql.query(XyzDocument, (req, res, ctx) => {
return res(
ctx.data({
xyz: [
{ ...xyzDefaultResponse },
{ ...xyzDefaultResponse, x: 90 }
],
})
)
}),
]
注意として、1箇所型エラーが出ている部分を@ts-ignoreしている部分があります。 補完も効きますし、おおよその型はあっているのですが、細部のパラメータが一部オプショナルか否でエラーを吐いていました。 もしかしたらcode-generateの設定次第で回避できるかもしれませんが、mswのAPIドキュメントにもまだtyped-document-nodeを使ったモック定義の記載は無いので、多少の不都合は一旦目をつぶってまずは使ってみています。 (ちなみにtyped-document-node対応はこちらのPRで2021年7月にマージされています)
次に、上記のHandlerを元にしたサーバを定義します。
src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
ちなみに、関数名はsetupServerとなっていますが、実際にサーバを立ち上げるわけではなく、ネイティブのapiリクエストモジュールを拡張してレスポンスの変更を実現しています。 (なので、実際にjest上のGraphQLクライアントがどのGraphQLエンドポイントを見ているかは関係ありません)
最後にテスト実行時に上記を呼び出して、リクエストのインターセプトを行います。
jest.setup.ts
import '@testing-library/jest-dom/extend-expect'
import { server } from '~/src/mocks/server'
beforeAll(() => {
server.listen()
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => server.close())
jest.config.js
module.exports = {
...
setupFilesAfterEnv: ['./jest.setup.ts'], // 追加
...
jest上でmswを利用できる様にはなったのですが、効率化のためにApollo Clientの初期化周りを行うテスト用のRender関数を定義します。
src/utils/testUtil.tsx
import React from 'react'
import { GraphQLHandler, GraphQLRequest } from 'msw'
import {
ApolloClient,
ApolloProvider,
createHttpLink,
InMemoryCache,
} from '@apollo/client'
import { render } from '@testing-library/react'
import { server } from '~/src/mocks/server'
const link = createHttpLink({
uri: 'GraphQLエンドポイント',
credentials: 'same-origin',
})
const client = new ApolloClient({
link,
uri: 'GraphQLエンドポイント',
cache: new InMemoryCache(),
})
export const testRenderer =
(children: React.ReactNode) =>
(responseOverride?: GraphQLHandler<GraphQLRequest<never>>) => {
if (responseOverride) {
server.use(responseOverride)
}
render(<ApolloProvider client={client}>{children}</ApolloProvider>)
}
実際のテストは以下の様になります。 下記コード内のSampleページコンポーネントは、ページ表示時にGraphQLエンドポイントを叩いて、結果をテーブル表示するだけのページを想定しています。
pages/sample.spec.tsx
import { graphql } from 'msw'
import { testRenderer } from '~/src/utils/testUtil'
import {
XyzDocument,
} from '~/src/graphql'
import SamplePage from './sample.page'
describe('Sample Page', () => {
const renderPage = testRenderer(<SamplePage />)
it('フェッチしたレスポンスをテーブル表示する', async () => {
renderPage()
const target = await screen.findAllByTestId('j-sample-tr')
expect(target.length).toBe(2)
})
また、useQuery時のパラメータを検証したい場合は、renderPageの引数に新しいhandlerを渡し、jest.fn()を内部で呼び出します。
pages/sample2.spec.tsx
it('フェッチしたレスポンスをテーブル表示する', async () => {
const queryInterceptor = jest.fn()
renderPage(graphql.query(XyzDocument, (req, res, ctx) => {
queryInterceptor(req.variables)
return res(
ctx.data({
xyz: [
{ ...xyzDefaultResponse },
{ ...xyzDefaultResponse, x: 90 }
{ ...xyzDefaultResponse, x: 0, y: 90 } // パラメータを変更することもできる
],
})
)
}))
// assert
await waitFor(() => expect(queryInterceptor).toHaveBeenCalledTimes(1))
await waitFor(() =>
expect(queryInterceptor).toHaveBeenCalledWith({
formula: 'x^2 + y^2',
})
)
})
前提として、弊社ではフロントエンドの単体テストのカバレッジはまだまだ高いとは言えず、リファクタ時の心理的安全性が保たれているとは言えない状況です。 これまではメンバーも少なかったため、コード全体を各開発メンバーがある程度把握していたためなんとかなっていた部分もありますが、嬉しいことに新しいメンバーがJOINし、チームも徐々に大きくなってきました。 そんな中で今回の新機能に関して、どの様な戦略で単体テストを書いていくべきかはかなり考えました。 テストにより開発コストが大幅に増えたり、効果的でないテストばかり書いていると、テストを書くモチベーションが保てなくなるので、そうならない様にかなり取捨選択しています。
まず今回の新機能に対するリポジトリでは、単体テストをMUSTで書く範囲をpageコンポーネントのみと定義しました。カバレッジの計測も範囲を絞って計測した上で、この範囲におけるカバレッジをできるだけ高いレベルで保ちたいと思っております。
共通で利用するコンポーネントに対する単体テストについては、以下の理由でMUSTとはしていません。 * pageコンポーネント側で満たすべき要件に相当するテストケースが網羅されていれば、必要最低限の挙動は充たせているはず * 大きな変更やリファクタの際にはコンポーネント自体を作り直すことが多いため、テストを捨てるケースも多い(弊社では) ただし、必要に応じてPRレビュー時にテストコードをリクエストすることはあるとおもいます
社内のテストを書く慣習がもっと高まってきたら、テストを書く範囲を広げて行きたいと思っています。 (いつかt_wadaさんの前でも胸を張ってコードをかけるようにしていきたいです)
pageコンポーネント単位なので、基本的にはアプリケーションが仕様通りに動作するかどうかを担保するインテグレーションテストを書いていくことになります。
*.spec.ts
にそのページで期待される挙動がわかるようにテストケースを作成していくことになります。
テストファイルを観れば、そのページを開発していないメンバーでも仕様がわかる状態を目指します。
テストの種類(ユニット・インテグレーション・リグレッション)については下記を観てもらうとわかりやすいです。こちらもとても参考になりました!
フロントエンドの単体テストに関してのベストプラクティスは諸説あると思いますが、コストを抑えつつ、最低限品質を保つことができるテストをかけるのが理想だと思っています。 ここでいう最低限はプロダクトや組織によると思っていて、弊社の組織と事業の規模からこの様なテスト方針を定めているのですが、ステージが変わればまた変わっていくと思います。 Nextなステージを知っている方の知見もお借りしたいので、興味ある方は是非一度を弊社採用をご覧ください。