Storybook + CypressでJavaScriptをテストする

UI開発者 吉田

この記事はミツエーリンクスアドベントカレンダー2019 - Qiitaの4日目の記事です。

当社におけるWebサイトの構築プロジェクトでは、複数名のフロントエンドエンジニアで開発チームを構成することが多く、HTML/CSS設計とJavaScript開発の担当者が別々にアサインされるケースも珍しくありません。
また、最近はReactやVue.jsなどのJavaScriptフレームワークを利用した開発も盛んですが、プロジェクトによっては要件にマッチしていないこともあり、まだまだJavaScriptフレームワークを利用しないHTML + CSS + JavaScriptでの開発が多い印象です。

「JavaScriptフレームワークを利用していないWebサイトの構築プロジェクトにおいて、HTML/CSS設計の工程が完了しJavaScript開発者にボールが渡った」というシナリオを仮定すると、JavaScript開発の工程ではどういったテストのアプローチがあるでしょうか。本記事ではUI開発環境のStorybookとブラウザテストツールのCypressを利用してJavaScript開発時のテストについて考えていきます。

ビジュアルのテスト

HTML/CSS設計の工程では、「デザインカンプと実装されたウィジェットのビジュアルを比較する」デザイナーチェックや「対象ブラウザでのレンダリングで崩れがないことを確認する」ブラウザチェックまでの検証が終わっているため、JavaScript開発の工程では引き継いだビジュアルの品質を維持していく必要があります。

デグレードが起こっていないか検証するリグレッションテストにはスナップショットテストを実施するケースもありますが、JavaScriptフレームワークを使用していないシンプルなHTMLでは要素や属性がロジックによって出力されるわけではないため、スナップショットテストの有用性が薄れてしまいます。また、見た目に変化がなかったとしても、ソースコード上の要素や属性に差異があれば簡単にテストが崩れてしまう懸念もあります。

「ビジュアルの品質を維持していく」ということにフォーカスすると、JavaScript開発の工程におけるビジュアルのテストはブラウザ上に表示された最終的な成果物をスクリーンショットで保存し、変更前後を比較するビジュアルリグレッションテストが適していると考えます。

Storybookの環境構築

今回の手法でビジュアルリグレッションテストを行う場合、HTML/CSS設計の工程からStorybook上でウィジェットが管理されている必要があるため、まずはStorybookの環境構築を行います。
※Node.jsがインストールされていることを前提としています。

セットアップ

プロジェクトのルートディレクトリで下記のコマンドを実行すると、Storybookに必要なファイルや設定が自動的にセットアップされます。

npx -p @storybook/cli sb init --type html

初回起動

セットアップが完了したら、下記のコマンドでStorybookを起動します。

npm run storybook

自動でブラウザが立ち上がり、Storybookのページが表示されます。

Storybookデフォルト起動画面

起動が確認できたら、次のステップのため一度Storybookを終了します。

HTMLローダー・Sassローダーのインストール

ストーリーファイル(.stories.js)からHTMLファイルやSCSSファイルを読み込めるように下記のパッケージをインストールします。

  • html-loader
  • extract-loader
  • node-sass
  • sass-loader
  • css-loader
  • style-loader
npm install html-loader extract-loader node-sass sass-loader css-loader style-loader --save-dev

続けて.storybookディレクトリ内にwebpack.config.jsを作成します。

webpack.config.js

const path = require('path');

module.exports = async ({ config, mode }) => {

  // for Sass
  config.module.rules.push({
    test: /\.scss$/,
    use: ['style-loader', 'css-loader', 'sass-loader'],
    include: path.resolve(__dirname, '../'),
  },

  // for HTML
  {
    test: /\.html$/,
    use: ['extract-loader', 'html-loader'],
    include: path.resolve(__dirname, '../')
  });

  return config;
};

ストーリーの追加

初期状態で表示されている「Hello World」のコンポーネントは下記のファイルに内容が記述されています。
stories/index.stories.js

今回は簡単な折り畳みウィジェットを作ってテストしたいので
index.stories.jsは削除し、下記のようにディレクトリとファイルを追加します。
stories/collapse/collapse.stories.js

続いて読み込むSCSSファイルとHTMLファイルを作成します。
HTML/CSS設計の工程で、折り畳みウィジェットの「開いた状態」と「閉じた状態」の見た目を用意していると想定し、それぞれ「static-show.html」と「static-hide.html」で作成します。

この節で追加するファイルは下記の通りです。

stories
    └── collapse
        ├── collapse.stories.js
        ├── _collapse.scss
        ├── static-show.html
        └── static-hide.html

ソースコードはそれぞれ下記のようになります。

collapse.stories.js

import { storiesOf } from '@storybook/html';

import './_collapse.scss';

import staticShowHtml from './static-show.html';
import staticHideHtml from './static-hide.html';

const stories = storiesOf('Widgets/Collapse', module);

stories.add('static-hide', () => staticHideHtml);
stories.add('static-show', () => staticShowHtml);

_collapse.scss

@charset "UTF-8";

.button {
  border-radius: 5px;
  border: 1px solid #212529;
  color: #212529;
  display: inline-block;
  font-size: 1rem;
  line-height: 1.5;
  padding: 5px 15px;
  text-decoration: none;
}

.box {
  border: 1px solid #212529;
  padding: 15px;

  &.is-hide {
    display: none;
  }
}

static-show.html

<p><a class="button" href="#collapseTarget">collapse trigger</a></p>
<div class="box" id="collapseTarget">
collapse contents
</div>

static-hide.html

<p><a class="button" href="#collapseTarget">collapse trigger</a></p>
<div class="box is-hide" id="collapseTarget">
collapse contents
</div>

HTML/CSS設計の工程から引き継いだ状態の設定としては以上になります。
ここでもう一度Storybookを起動して表示を確認してみます。

npm run storybook

Storybook立ち上がり、先ほど作成した折り畳みウィジェットのサンプルが表示されているかと思います。

折り畳みウィジェット追加後のStorybook起動画面

Cypressの環境構築

Storybook環境でテストが行えるようにCypressの環境を構築します。

インストール

プロジェクトのルートディレクトリで下記のコマンドを実行し、Cypressをインストールします。

npm install cypress --save-dev

セットアップ

Cypressをインストール後に下記コマンドを実行すると、起動に必要なファイルや設定が自動的にセットアップされ、Cypressが起動します。

npx cypress open

cypressディレクトリが作成され、設定ファイルやサンプルのテストファイル(.spec.js)が格納されているかと思います。
起動が確認できたら、次のステップのため一度Cypressを終了します。

ビジュアルリグレッションテストに必要なパッケージのインストール

ビジュアルリグレッションテストに必要な下記のパッケージをインストールします。

  • cypress-image-snapshot
  • start-server-and-test

cypress-image-snapshotは、スクリーンショットを取得するために必要なパッケージです。
start-server-and-testは、1つのコマンドでStorybookの起動とCypressでのテストを実行するために導入します。

npm install cypress-image-snapshot start-server-and-test --save-dev

続けて、cypress/plugins/index.jsとcypress/support/commands.jsを修正します。

index.js

const { addMatchImageSnapshotPlugin } = require('cypress-image-snapshot/plugin');

module.exports = (on, config) => {
  addMatchImageSnapshotPlugin(on, config);
}

commands.js

import { addMatchImageSnapshotCommand } from 'cypress-image-snapshot/command';

addMatchImageSnapshotCommand({
  failureThreshold: 0.00,
  failureThresholdType: 'percent',
  customDiffConfig: { threshold: 0.0 },
  capture: 'viewport',
});

ビジュアルリグレッションテスト

テストコード追加

cypress/integration/examplesディレクトリにはサンプルのテストファイルが格納されていますが、今回は必要ないのでexamplesディレクトリごとすべて削除します。

cypress/integration/widget/collapseディレクトリを新しく作成しstorybook_visual-tests.spec.jsテストファイルを追加します。

ソースコードは下記のようになります。

storybook_visual-tests.spec.js

describe('Collapse', () => {
    context('Visual regression tests', () => {
        it('Should match previous screenshot "static-hide"', () => {
            cy.visit('http://localhost:6006/?path=/story/widgets-collapse--static-hide');
            cy.get('#storybook-preview-iframe').matchImageSnapshot();
        });
        it('Should match previous screenshot "static-show"', () => {
            cy.visit('http://localhost:6006/?path=/story/widgets-collapse--static-show');
            cy.get('#storybook-preview-iframe').matchImageSnapshot();
        });
    });
});

テストの初回実行

package.jsonの"scripts"の内容を下記のように修正します。
"test"を修正し"cypress:run"を追記しています。

  "scripts": {
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook",
    "cypress:run": "cypress run",
    "test": "start-server-and-test storybook http-get://localhost:6006 cypress:run"
  }

下記コマンドでビジュアルリグレッションテストを実行します。

npm test

ブラウザでStorybookの画面が表示され、コマンド画面ではCypressのテストが実行されます。
テストが完了すると下記ディレクトリに各パーツのスクリーンショットが保存されます。

cypress
    └── snapshots
	    └── widget
		    └── collapse
			    └── storybook_visual-tests.spec.js
			        ├── Collapse -- Visual regression tests -- Should match previous screenshot static-hide.snap.png
			        └── Collapse -- Visual regression tests -- Should match previous screenshot static-show.snap.png

初回のテストは必ず成功し、保存されたスクリーンショットが比較対象元のベンチマーク画像となります。次回のテスト実行時に、初回で保存されたスクリーンショットと比較して差異がないか検証します。

折り畳みウィジェットの機能実装

これまでのステップではHTML/CSS設計から引き継いだ状態を作りましたが、ここからはいよいよ折り畳みウィジェットの機能を実装します。
JavaScriptで挙動を実現するために、HTMLに修正を加えつつ下記のように追加・更新を行います。

stories
    └── collapse
        ├── _collapse.scss
        ├── collapse.js (追加)
        ├── collapse.stories.js (更新)
        ├── default.html (追加)
        ├── static-show.html (更新)
        └── static-hide.html (更新)

collapse.js

export class Collapse {
  constructor(element) {
    this.trigger = element.querySelector('.js-collapse-trigger');
    this.target = document.getElementById(this.trigger.getAttribute('aria-controls'));

    if (this.trigger && this.target) {
      this.target.classList.add('is-hide');
      this.bindEvents();
    }

    element.Collapse = this;
  }

  bindEvents() {
    this.trigger.addEventListener('click', this.toggle.bind(this));
  }

  toggle() {
    if (this.target.classList.contains('is-hide')) {
      this.show();
    } else {
      this.hide();
    }
  }

  show() {
    this.target.classList.remove('is-hide');
    this.trigger.setAttribute('aria-expanded', 'true');
  }

  hide() {
    this.target.classList.add('is-hide');
    this.trigger.setAttribute('aria-expanded', 'false');
  }
}

collapse.stories.js

import { storiesOf } from '@storybook/html';
import { useEffect } from '@storybook/client-api';
import { Collapse } from './collapse.js';

import './_collapse.scss';

import staticHideHtml from './static-hide.html';
import staticShowHtml from './static-show.html';
import defaultHtml from './default.html';

const stories = storiesOf('Widgets/Collapse', module);

stories.add('static-hide', () => staticHideHtml);
stories.add('static-show', () => staticShowHtml);
stories.add('default', () => {
    useEffect(() => {
      new Collapse(document.querySelector('.js-collapse'));
    });

    return defaultHtml;
});

default.html

<div class="js-collapse">
<p><button class="button js-collapse-trigger" type="button" aria-expanded="false" aria-controls="collapseTarget">collapse trigger</button></p>
<div class="box" id="collapseTarget">
collapse contents
</div>
</div>

static-show.html

<p><button class="button" type="button">collapse trigger</button></p>
<div class="box">
collapse contents
</div>

static-hide.html

<p><button class="button" type="button">collapse trigger</button></p>
<div class="box is-hide">
collapse contents
</div>

機能実装後のビジュアルリグレッションテスト

折り畳みウィジェットの機能を実装したので、あらためてビジュアルリグレッションテストを実行します。

npm test

今度はテストに落ちてしまうかと思います。ビジュアルリグレッションテストで差異があった場合は、下記のディレクトリに比較結果画像が格納されます。

cypress
    └── snapshots
	    └── widget
		    └── collapse
			    └── storybook_visual-tests.spec.js
			    	└── __diff_output__
				        ├── Collapse -- Visual regression tests -- Should match previous screenshot static-hide.diff.png
				        └── Collapse -- Visual regression tests -- Should match previous screenshot static-show.diff.png

下記の画像は問題が検知されたstatic-show.htmlのテスト結果(Collapse -- Visual regression tests -- Should match previous screenshot static-show.diff.png)になります。

ビジュアルリグレッションテスト結果

どうやらJavaScriptを実装した際に、折り畳みウィジェットのトリガーとなるa要素をbutton要素へ変えていたため、button要素のデフォルトスタイルによる差異が発生してしまったようです。

button要素でもa要素と同じ見た目になるようにスタイルを調整します。

_collapse.scss

.button {
  background-color: transparent;
  border-radius: 5px;
  border: 1px solid #212529;
  color: #212529;
  display: inline-block;
  font-family: auto;
  font-size: 1rem;
  line-height: 1.5;
  padding: 5px 15px;
  text-decoration: none;
}

この状態でもう一度テストします。

npm test

今回は無事にテストが通ったかと思います。

ロジックのテスト

これまでビジュアルのテストを行ってきましたが、ロジックのテストはどういった方法がとれるでしょうか。
有名なテストフレームワークとしてはJestがあります。Jestはほかのテストフレームワークと比較しても人気が高く、Storybookと同じFacebook製ということもありStorybookとの親和性も高いです。
しかし、今回JestではなくCypressをテストツールとして選択した理由は、Jestは実際のブラウザではなく仮想環境(JSDom)で実行されるのに対して、Cypressは実際のブラウザ環境で実行されるためブラウザの機能をそのまま使用できるメリットがあったからです。また、前述の理由によりデバッグがしやすく、通常のフロントエンドでのJavaScript開発と近い感覚でテストコードが書けることも1つの強みだと思います。

Cypressのカスタムコマンド追加

CypressでStorybook環境のDOMを取得するためには少し工夫が必要になります。
また、その処理を各テストファイルに定義するのは冗長なためCypressのカスタムコマンドとして定義します。

cypress/support/commands.jsに下記のソースコードを追記します。

commands.js

Cypress.Commands.add('iget', (selector) => {
    return cy.get('#storybook-preview-iframe').then((iframe) => {
        return cy.wrap(iframe.contents().find(selector));
    });
});

ユニットテスト テストコード追加

cypress/integration/widget/collapseディレクトリにstorybook.spec.jsテストファイルを追加します。 ソースコードは下記のようになります。

storybook.spec.js

describe('Collapse', () => {
    beforeEach(() => {
        cy.visit('http://localhost:6006')
    });

    context('Unit tests', () => {
        it('Should show a collapse target', () => {
            cy.get('a#explorerwidgets-collapse--default').click();

            cy.iget('.js-collapse').then(($element) => {
                $element[0].Collapse.show();
            });

            cy.iget('.js-collapse-trigger').should('have.attr', 'aria-expanded', 'true');
            cy.iget('#collapseTarget').should('be.visible');
        });

        it('should call hide method if "is-hide" class is not present', () => {
            cy.get('a#explorerwidgets-collapse--default').click();

            cy.iget('#collapseTarget').then(($element) => {
                $element.removeClass('is-hide');
            });

            cy.iget('.js-collapse').then(($element) => {
                const collapse = $element[0].Collapse;

                cy.spy(collapse, 'hide');

                return collapse;
            }).then((collapse) => {
                collapse.toggle();
                expect(collapse.hide).to.be.called;
            });
        });
    });
});

it('Should show a collapse target', () => {}では
Collapse.show()を実行した際に
折り畳みウィジェットのトリガーのaria-expanded属性値がtrueであること
折り畳みウィジェットのコンテンツが表示されていること
を検証しています。

it('should call hide method if "is-hide" class is not present', () => {}では
折り畳みウィジェットのコンテンツのclass属性値にis-hideが付与されていない場合
Collapse.toggle()を実行した際に
Collapse.hide()が呼び出されること
を検証しています。

結合テスト テストコード追加

ユニットテストと同じstorybook.spec.jsテストファイルに下記ソースコードを追記します。

storybook.spec.js

describe('Collapse', () => {

// ...
// 省略
// ...

    context('Integration tests', () => {
        it('Should hide a collapse target if clicked twice', () => {
            cy.get('a#explorerwidgets-collapse--default').click();

            cy.iget('.js-collapse-trigger').click().click();

            cy.iget('.js-collapse-trigger').should('have.attr', 'aria-expanded', 'false');
            cy.iget('#collapseTarget').should('not.be.visible');
        });
    });
});

it('Should hide a collapse target if clicked twice', () => {}
折り畳みウィジェットのトリガーが2回クリックされた際に
折り畳みウィジェットのトリガーのaria-expanded属性値がfalseであること
折り畳みウィジェットのコンテンツが非表示であること
を検証しています。

テスト実行

テストを実行します。

npm test

ビジュアルリグレッションテスト、ユニットテスト、結合テストがすべて成功していれば、Storybook + Cypressのテスト環境構築は完了です!

まとめ

フロントエンドにおけるJavaScript開発は、データを扱う処理よりもブラウザでの表示・挙動を処理するため、テストのハードルが高いと言われてきました。しかし、最近ではStorybookやCypressのようなツールを活用することによって、以前よりもずっとテストの戦略が立てやすくなっています。

また、テストを導入することにより、「要件通り正しく処理されている」のようなプログラムの品質維持はもちろんですが、「テストしやすく適切な粒度でメソッドが設計されている」のようなプログラムの品質向上も期待できます。

これまでフロントエンドのJavaScript開発でテストを実施したことがなかった方も、StorybookやCypressなどのツールを活用してテストを導入してみてはいかがでしょうか。