この記事はenechain Advent Calendar 2024の21日目の記事です🎄
はじめに
こんにちは。enechainでソフトウェアエンジニアをしている@nakker1218です。
私たちのチームでは、電力の取引仲介を行う同僚(ブローカー)たちが使う、注文の統合管理システムeNgineの開発しています。
ブローカーは日々1分1秒を争いながら収益を上げているため、システムが停止したり機能に不具合が生じると、事業に非常に大きな影響を与えてしまいます。
一方、電力業界はドメインが複雑で、要件や機能も多いため、バグが発生するリスクが高いです。 実際にこれまで以下のような問題が発生したことがありました。
- Validな注文なのに、バリデーションが通らなくなった
- 特定の選択肢が選択されているときに表示されるはずのフォームが表示されない
- ショートカットキーを使ったインタラクションが一部正しく動作しない
- キーボード操作によるテーブルのカーソル移動機能が正常に動かない
これらの問題を人間によるQAテストだけですべて担保しようとすると、開発が進むにつれQAにかかる時間が増え続けてしまいます。 そうなるとリリース頻度が低下し、価値提供の総量が減少してしまいます。
そこでeNgineではQAに頼り切らず、いかに自動テストで担保する範囲を広げるかを検討しました。
テスト戦略
自動テストを拡充していくにあたって、まず、eNgineにおける自動テストの目的を整理しました。
自動テストの目的
自動テストの目的としては、ユーザーが業務で行う操作が問題なく動作することを保証することとしました。その理由として以下の2点があります。
- enableかどうかを返す関数をisDisabledに渡してしまっていたといったように、コード的には問題ないものでも、業務的に想定している挙動になっていないことがある
- コードの変更頻度のほうが、ユースケースの変更頻度より多い可能性が高い
自動テストを行うことで、安全で高速なリリースを目指します。
Frontendにおける自動テストの種類
自動テストをどう実装するかを考える前に、フロントエンドにおける自動テストはどのような種類があるのかを調べました。 すると、大きく分けて以下の4つがあることがわかりました。
End to End(E2E)
End to Endテストは、実際のAPIに繋いで、機能が期待通り動いていることをユーザー視点でテストを行う手法です。 主にクリティカルユーザージャーニーが想定通りに動作することを担保します。
Sociable Tests
https://martinfowler.com/bliki/UnitTest.html
Sociable Testsは、他の機能とのつながりを可能な限り保ったまま単一の機能のテストを行う手法です。 各ユースケースに対して、コンポーネントの振る舞いが期待通りかを担保します。
Solitary Tests
https://martinfowler.com/bliki/UnitTest.html
Solitary Testsは、モックによりテスト対象と他のつながりを隔絶してテストを行う手法です。 コンポーネントやロジックが意図通りに実装されているかを担保します。
TypeScriptによる型チェックや、ESLintを使った静的解析によってコードの品質を担保します。
自動テストは種類によっては作成や保守のコストが大きいですが、eNgineはブローキングチームが存在する限り運用され続けるアプリケーションであるため、テストへの投資は十分にリターンが見込めると判断し、上記すべての自動テストに取り組むことを決めました。
実装方針
それぞれのテスト種別に対する実装方針は以下のように決定しました。
End to End(E2E)
E2Eテストに関しては、既にQAチームが他のプロダクトで、MagicPodを使ってE2Eテストの自動化にチャレンジしています。QAチームの取り組みについてはこちらの記事をご覧ください。
そのため、E2Eに関してはMagicPodを使って実装することにします。
Sociable Tests
Sociable Testsに関しては、外部の依存関係(他のコンポーネントやAPI, Stateなど)と連携して動作するコンポーネントを対象として以下の2点をテストします。
- 外部の依存関係の状態においてコンポーネントが期待通りに描画されること
- ユーザーの操作が期待通りにできること
実装としてはStorybookとPlaywrightを使ってインタラクションテストを行うことにします。
Solitary Tests
Solitary Testsに関しては、依存関係を持たない単体のコンポーネントや関数を対象として、コンポーネントやロジックが期待通りの挙動をすることをテストします。
コンポーネントのテストは、Sociable Testsと同様に、StorybookとPlaywrightを使ってインタラクションテストを行います。
ロジックのテストは、Viteを使ってbuildをしていること、社内の他プロダクトでも採用例があることからVitestを用いて行います。
Static
Staticテストに関しては、enechainでは全社共通のESLint Configがあり、各プロダクトが導入することで横断的にコード品質を担保しています。ESLint Configに関する取り組みについてはこちらの記事をご覧ください。
そのため他のプロダクトと同様に全社共通のESLint Configを用いて実装することにします。
本記事では、E2EやStaticに関しては他の記事で紹介されていることから、StorybookとPlaywrightを使ったインタラクションテストに絞って詳しく説明していきます。
なぜStorybook+Playwrightなのか
フロントエンドでコンポーネントのインタラクションテストを行う方法としては、Storybook Test runnerを使う方法や、Playwright、 React Testing Libraryを使う方法が考えられます。
Storybook Test runnerは、Storybook上でインタラクションテストを実行できるためデバッグがしやすく、また内部的にPlaywrightが使われていることからクロスブラウザテストも可能です。
しかし、Storybook Test runnerはdescribe
、test
に相当するものがありません。
そのためplay
関数内にすべてのケースを記載するか、テスト項目ごとにStoryをつくる必要があり、テストケースが増えるごとにカタログとしてのStoryの見通しが悪くなってしまいます。
そこで、Storybook Test runnerではなく、Playwrightをつかってインタラクションテストを実現することにしました。StorybookのURLをPlaywrightに読み込ませることでコンポーネントごとのインタラクションテストを実現します。
Playwrightを選定した理由としては以下の2点があります。
- 実際のブラウザ上でテストができること
実際のブラウザを外から操作するため、jsdom
では発見できないブラウザ特有の問題を自動テストで発見できます。Cypressも選択肢に上がっていましたが、社内の別のチームでPlaywrightが導入されており、ナレッジがあったことからPlaywrightを選定しました。Playwrightを使ったリグレッションテストの事例はこちらを参考にしてください。 techblog.enechain.com - PlaywrightのVS Code拡張が強力
Playwrightの開発元であるMicrosoftが公式に提供しているVS Code拡張が強力で、テストを書く労力が下がります。テストを書くハードルを下げるためにもこのVS Code拡張があることは選定の大きな要素でした。 このVS Code拡張ではTestのコードジェネレーターやTrace Viewerを使うことでアクションの前後に何が起こったかを視覚的に確認できます。
VS Code拡張についてはこちらの記事が詳しいので参考にしてください。
また、StorybookとPlaywrightの連携は強化されています。 Portable storiesとPlaywright CTを使った手法でもインタラクションテストは可能ですが、まだ情報が少なく、experimentalで不安定なことから今回は見送りました。
将来的にはPortable stories + Playwright CTを使った方法に移行したいと思っていますが、Playwrightで書いておけば移行も簡単であると予想しています。
Storybookの整備
テストを書いていく前に、まずコンポーネントが取りうる状態をStorybookのStoryで定義します。 コンポーネント内部の状態や、コンポーネントが依存するAPIはモック化し、Storyに読み込んで行きます。
モック化
通信のモック化
通信はMSWを使ってモック化します。
Storybookの msw-storybook-addon を使うことで、Storybook上でMSWを使うことができるようになります。
導入時には下記の記事を参考にしました。
- https://zenn.dev/rabbit/articles/dd9b04940b93fe
- https://zenn.dev/ryo_kawamata/articles/mock-api-server-with-msw
MSWではStorybookのparameterに、Handlerを設定することでエンドポイント毎にインターセプトできます。
export const Default: StoryObj<typeof EditOrderModal> = { parameters: { msw: { handlers: [] } } }
eNgineではAPI通信にConnect Protocolを使っているため、以下のように記載してモックしています。connect-esの通信はhttp用のhandlerを使ってインターセプトが行えます。
export const Default: StoryObj<typeof EditOrderModal> = { parameters: { msw: { handlers: [ http.post(`$baseUrl/${Service.typeName}/${methods.method.name}`, () => { return HttpResponse.json({}) }) ] } } }
しかし、これではどのrpcに対してMockしているかがわかりにくいです。
そこで、eNgineではTanstack Queryのラッパーであるconnect-queryを使っているため、connect-queryの型に合わせてHandlerを作る関数を作成します。
gRPCのprotoから生成された型でモック化すれば、APIが変更されたときコンパイルエラーとなり検知できるので便利です。
余談ですが、ConnectRPCでは、https://github.com/connectrpc/connect-playwright-es というライブラリでPlaywrightと簡単に連携できますが、connect-esを直接使っていることが前提になっているためので今回は採用を見送っています。
import { Message, ServiceType } from '@bufbuild/protobuf' import { MethodUnaryDescriptor } from '@connectrpc/connect-query' import { createMethodUrl } from '@connectrpc/connect/protocol' import { HttpHandler, HttpResponseResolver, PathParams, http } from 'msw' type CreateConnectMswHandlerProps = { baseUrl?: string } type CreateConnectMswHandlerReturn = < I extends Message<I>, O extends Message<O>, >( methodDescriptor: MethodUnaryDescriptor<I, O>, resolver: HttpResponseResolver<PathParams, I, O>, ) => HttpHandler export function createConnectMswHandler( { baseUrl = '' }: CreateConnectMswHandlerProps = { baseUrl: '' }, ): CreateConnectMswHandlerReturn { return (methodDescriptor, resolver) => { // https://github.com/connectrpc/connect-query-es/blob/main/packages/connect-query/src/call-unary-method.ts#L38 const service = { typeName: methodDescriptor.service.typeName, methods: {}, } satisfies ServiceType const methodUrl = createMethodUrl(baseUrl, service, methodDescriptor) return http.post(methodUrl, resolver) } }
export const Default: StoryObj<typeof EditOrderModal> = { parameters: { msw: { handlers: [ createHandler(edit, () => { const response = new EditResponse({}) return HttpResponse.json<EditResponse>(response) }), ] } } }
グローバルステートのモック化
eNgineではグローバルステートの管理にJotaiを使っているのでこちらもモック化が必要です。
Jotaiのモック化は、StorybookのDecoratorsの中でJotaiProviderを使い、storeにモックするデータを保存することで各Storyごとにモックデータを差し込みます。
preview.ts
const withJotai = (Story: Function, context: StoryContext) => { const { atoms, values } = context.parameters.jotai ?? {} const [store] = useState(createStore()) useEffect(() => { if (atoms == null) { return } for (const atomName of Object.keys(atoms)) { const atom = atoms[atomName] const value = values[atomName] store.set(atom, value) } }, [store, atoms]) if (atoms == null) { return <Story /> } return ( <JotaiProvider store={store}> <Story /> </JotaiProvider> ) } const preview: Preview = { globalTypes, loaders: [mswLoader], decorators: [ ... withJotai, ], }
export const Default: StoryObj<typeof EditOrderModal> = { parameters: { jotai: { atoms: { selectedCells: selectedCellsAtom, }, values: { selectedCells: [cell], }, }, } }
Story
これでStory側の準備は完了です。Storybook上で、モックされたデータに合わせてコンポーネントを描画・操作できるようになりました。
Playwrightの整備
続いて、先ほど作成したStoryに対してインタラクションテストを書いていきます。
PlaywrightでStorybookを読み込む
Storybookをhttp-server上で立ち上げ、PlaywrightからページとしてStorybookにアクセスします。
{ ... "scripts": { "storybook:ci": "pnpm http-server storybook-static --port 6006", ... } }
test('Priceが編集できること', async ({ page }, testInfo) => { await page.goto( 'http://localhost:6006/iframe.html?id=editordermodal--outright&viewMode=story', ) ...テストを書く })
インタラクションテストの実装
続いて、実際にPlaywrightのテストを書いていきます。Playwrightではベストプラクティスが公開されており、それに則って実装していきます。
eNgineでは、インタラクションテストはGherkin記法(Given-When-Thenパターン)で書いています。 複雑なインタラクションも整理して書けるため、テストコードの保守性が向上します。
実際のテストがこちらです。
import { expect, test } from '@playwright/test' test.describe('EditOrderModal', () => { test.describe('Outright', () => { test.beforeEach(async ({ page }) => { await page.goto( 'http://localhost:6006/iframe.html?id=editordermodal--outright&viewMode=story', ) const anchor = page.getByRole('button', { name: 'Click Here!' }) await anchor.click() }) test('Priceが編集できること', async ({ page }, testInfo) => { // Given const priceSpinbutton = page.getByRole('spinbutton', { name: 'Price', exact: true, }) await expect(priceSpinbutton).toHaveValue('10.00') await takeSnapshot(page, testInfo) // When await priceSpinbutton.fill('20.00') // Then await expect(priceSpinbutton).toHaveValue('20.00') }) }) })
これらのテストをGitHub ActionsでPRごとに実行し、デグレが発生していないかを検証しています。
導入して
eNgineでは24年8月からインタラクションテストを導入し、500ケース以上のテストを実装してきました。 Playwrightを活用したコンポーネントのインタラクションテストには、以下のようなメリットがありました。
- ユースケースをテストで担保しているので、コンポーネントの変更時に実装漏れを防ぎやすくなった
コンポーネントのパターンがStorybookで網羅された
コンポーネントのパターンがStorybook上で確認できるので、デザイナーやPdMに共有しやすくなりました。
また、アプリケーション上で検証するのがめんどくさいパターンのとき特に、Storybookを見ながら開発ができるので開発体験が良くなりました。デグレの早期発見ができるようになった
普段の機能開発だけでなく、ライブラリのアップデート時にもデグレが検知しやすくなり、ライブラリのバージョンアップが躊躇いなくできるようになりました。
実際に依存しているTanstack Formの挙動がライブラリ側でも意図しない変化が発生したことによる、機能デグレにも早期発見ができました。
また、機能デグレだけでなく、コンポーネントのStorybookが整備されたことで、ChromaticのVRTによってデザインデグレも検知できるようになりました。
導入によって、人間によるQAの負荷を軽減しながらバグを早期に発見できるようになっています。
逆に、導入後に感じたデメリットとしては以下のようなものがありました。
ユースケースのカバレッジを担保する方法が必要
ユースケースの認識が漏れていて担保されていないコンポーネントがあり、リリース後に考慮が漏れているパターンが見つかったケースがありました。
今後はテストケース作成のところからQAチームと密に連携して漏れを減らす施策を進めていきたいと考えています。実行時間が長い
現状PlaywrightのSharding機能を使って、分割実行しているものの全ケース実行すると10分程度かかってしまいます。 毎PRで10分かかるのは開発体験がよくないので、実行時間を短くする施策を進めています。 具体的には、GitHub Actionsの効率化や、コンポーネントの変更を検知して、変更があったコンポーネントに関連するテストのみ実行するようにしたいと考えています。- モックデータを作るのが大変
APIをモックしているため、なるべく本番データに近いデータを用意できれば信頼性を向上できます。 本番に近いモックデータをどのように作成し、管理していくかが今後の課題になっています。
最後に
ここまで読んでいただきありがとうございました!本記事が、同様の課題に直面している皆様にとって、参考になれば幸いです。
enechainでは、巨大なマーケットを支えるプラットフォームを一緒に構築する仲間を募集しています!要項は以下からご確認ください。