Mercari Advent Calendar 2018 19 日目です。
担当はメルカリ JP フロントエンドチームの @nekobato がお送りします。
メルカリ フロントエンドチームでは主に React を利用してフロントエンド構築を行っており、
その中のWeb Rearchitecture では Storybook による UI Testing に取り組んでいます。
今回はフロントエンドの UI Testing と Unit Testing を Storybook & Storyshots を利用して行っているという話をします。
Storybook とは
Storybook は個別の UI コンポーネントをブラウジングする開発環境を構築するモジュールです。
単独では目視による UI テストを可能にするのみですが、アドオンと組み合わせることで様々なテスト環境を構築することが可能になります。
Storybook に期待するもの
UI をテストする
- 要素の横幅を大きくはみ出る文字数を表示した時にどうなるのか
- 値が存在しない時の表示
- コンテナの横幅を変更した場合どういった表示になるのか
- データによって変化する全ての状態を例示する
上に挙げたものは一部ですが、作っている途中ならともかく、既に作ってしまった後だとデータが入り組んでいて確認がし辛いものとなってしまうことも多くあります。
そうなる前に、考えられるパターンをテストに書き出していつでも Storybook で参照できる状態にできることが理想です。
Storyshots による意義のある Unit Testing
Storyshotsは Storybook のアドオンの一つで、Storybook のコードを流用して Jest による Snapshot Testing を行えます。
フロントエンドの Unit Testing として代表的なものに Jest があります。
Jest は Snapshot 機能を提供しており、UI コンポーネント毎に Snapshot を記録しておくことで、コードを変更した際の UI 変更を検知できるようになります。
Snapshot が十分に UI コンポーネントを網羅しているならば、高度に複雑化したプロジェクトでもどの UI 部分に影響が出たのかをすぐに判別できるようになります。
しかし、Snapshot ファイルは人の目で読めないこともないですが、Unity の meta ファイル並には読むのが面倒臭く、コードの変更によって変化したビューを目視で確認することはできません。
Storybook と Storyshots の連携があれば、diff からすぐに該当の UI Comopnent の変更を目視で確認することが可能です。
忘却されない UI Testing のための仕組み
「コンポーネント一覧を管理したい」などのモチベーションで始まった Storybook は、
当初の発起人が居なくなったり、Storybook の優先度が下がってしまったりと様々な理由で更新されなくなることが多々起こります。
もし継続して Storybook を更新していきたいと考えるならば、Storybook 自体の意義をプロダクトにとってより重要な位置付けにすることと、
継続するための仕組みが必要になります。
Storyshots で Unit Testing と UI Testing を同時に行う
Storybook のコードが Storyshots にもなるということは、逆に言えば Unit Testing がそのまま UI Testing にもなるということで、「Storybook を書かなければいけない」というルールを作らなくても、Unit テストを書いていればそのままそれが Storybook になってくれます。
勿論それが実践的な Storybook サンプルかと言うともう少し考慮が必要になりますが、少なくとも該当のコンポーネントの Storybook が存在しないという事態は避けられます。
また、Storyshots はコードのカバレッジ率に貢献します。今まで Jest で Unit Test を書いていたならば、Snapshot のテストだけでも Storyshots に移行すればそのまま Unit Testing を継続して行えます。
このように Storybook を UI を管理する場ではなく、UI をテストするための Playground だと考えることもできます。
coverageThreshold を設定する
コードの変更が UI にどういった影響があるのか正しく検知するためには、
フロントエンド、とりわけ DOM 構造の部分は全て網羅することを目指すべきだと考えています。
そのため、CI の設定で coverageThreshold を、特に UI Components 部分は全て網羅するつもりで設定するのが理想です。
Storybook & Storyshots の構築
実際に構築した場合に、公式ドキュメントを頼れないポイントなどを解説していきます。
解説に使う主要な依存パッケージの構成は以下の想定です。
とりあえず最近のフロントエンドを詰め込んでみた構成でお送りします。
{ "@storybook/addon-storyshots": "4.1.1", "@storybook/react": "4.1.1", "jest": "23.6.0", "enzyme": "3.8.0", "react": "16.6.3", "react-apollo": "2.3.3", "styled-components": "4.1.2" }
基本的な部分は冗長になるので公式ドキュメントに任せますが、組み合わせの中で特徴的な部分を紹介します。
Storyshots を撮る際に require.context が無いと言われる
Jest で Snapshot を撮る時と同じように、Storyshots でもテストするコンポーネントと同じディレクトリ上にsnapshots
を置きたい場合が多いかと思います。
なので/.*.story.tsx$/
のように story ファイルを読み込みたいのですが、そのままだと__requireContext is not defined
と言われます。
babel-plugin-require-context-hook/register
と組み合わせることで動きます。
import { configure, addDecorator } from '@storybook/react'; import { setConfig } from 'next/config'; import * as React from 'react'; // Storyshots testの場合のみ動作 if (process.env.NODE_ENV === 'test') { require('babel-plugin-require-context-hook/register')(); } const req = require.context('../../components/', true, /.*\.story\.tsx$/); function loadStories() { req.keys().forEach(filename => req(filename)); } configure(loadStories, module);
Storyshots で enzyme を使う
react-test-renderer
ではなくenzyme
を使う場合はinitStoryshots.test
部分にカスタマイズしたテストを全部書いてしまいます。
shallow
は軽いですが子要素からの影響が確認できなくなるため、よしたほうが良いです。
import initStoryshots, { Stories2SnapsConverter } from '@storybook/addon-storyshots'; import styleSheetSerializer from 'Jest-styled-components/src/styleSheetSerializer'; import { addSerializer } from 'Jest-snapshot'; import { mount } from 'enzyme'; import toJson from 'enzyme-to-json'; addSerializer(styleSheetSerializer); initStoryshots({ configPath: './config/storybook', test: ({ story, context }) => { const converter = new Stories2SnapsConverter(); const snapshotFilename = converter.getSnapshotFileName(context); const storyElement = story.render(context); const tree = mount(storyElement); if (snapshotFilename) { expect(toJson(tree)).toMatchSpecificSnapshot(snapshotFilename); } } });
Storybook で共通の Stylesheet を使いたい
DOM は decorator として Story を wrap できます。
下記は Storyshots ではない、Storybook 上の場合に動く共通 Stylesheet を挿入しています。
if (process.env.NODE_ENV !== 'test') { addDecorator(story => ( <React.Fragment> <GlobalStyle /> {story()} </React.Fragment> )); }
enzyme は React.memo にまだ対応していない
enzyme は 2018 年 12 月 18 日現在、React.memo
に対応していません。
React.memo
を使った当該のコンポーネントをテストしたい場合は以下の回避方法が必要になるでしょう。
(React 公式が対応していないだけなのでこの Tips は明日不要になるかもしれない)
const HogeFC = (<div>{ ... }</div>); export const React.memo(HogeFC);
import { HogeFC as Hoge } from './Hoge'; storiesOf('Hoge', module).add('render', () => <Hoge />);
react-apollo で Query のテスト
react-apollo を使った Unit Testing、コンポーネントはなるべくQuery
に依存しない設計にするのが先決だと考えています。
react-apollo でQuery
を伴った UI Testing をする場合は、react-apollo が提供している<MockProvider>
を利用できます。
公式で指摘されている通り、実際の GraphQL Server に依存したテストは推奨できません。
Testing React components | Apollo Clientに Jest のサンプルがありますが、Storyshots と組み合わせる場合は Jest テストの記述部分をstoriesOf
に入れ替えるだけで動きます。
import { storiesOf } from '@storybook/react'; import { MockedProvider } from 'react-apollo/test-utils'; import { GET_DOG_QUERY, Dog } from './dog'; const mocks = [ { request: { query: GET_DOG_QUERY, variables: { name: 'Buck' } }, result: { data: { dog: { id: '1', name: 'Buck', breed: 'bulldog' } } } } ]; storiesOf('Dog', module).add('normal rendering', () => ( <MockedProvider mocks={mocks} addTypename={false}> <Dog name="Buck" /> </MockedProvider> ));
Storybook のアドオンに apollo-storybook-decorator もありますが、中身は単純な<ApolloProvider>
のようで、記述量は大して変わらなそうです。(試していない)
Conclusion
Storybook を利用した UI Testing と Unit Testing の手法を紹介しました。
フロントエンドのテストは以前よりも簡便になってきましたが、まだ一般的に現場で浸透しているとは言い難く、どのように開発フローに根付かせるかを考えていく必要があると思っています。
メルカリのフロントエンドチームでも、プロダクトが「書いて確認したほうが早い」フェーズから「影響がわからないので何も変更できない」に変わる前に仕組みを講じられればと日々取り組んでいます。
明日、20 日目の担当は @tarotarokun です。
引き続きお楽しみください。