Storybook による UI & Unit Testing のススメ

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 ファイル並には読むのが面倒臭く、コードの変更によって変化したビューを目視で確認することはできません。

Image from Gyazo

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 です。
引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加