😸

StorybookとPlaywrightがもたらす画期的なUIテスト

2023/04/04に公開

はじめに

StorybookとPlaywrightを連携してテストすることで、思っていた以上に良い開発体験が得られたので紹介します。

今回の記事で紹介するテストは以下のリポジトリで公開しています。
https://github.com/kavis777/sample-playwright

具体的には以下の点が最高でした。

  1. 独立したコンポーネント開発
    Storybookを利用することで、UIコンポーネントを独立して開発・テストできます。これにより、コンポーネントの再利用性が向上し、効率的な開発が可能になります。

  2. シナリオベースのテスト
    Playwrightを使ってシナリオベースのテストを実行できます。これにより、ユーザーの実際の操作に近い状況でのテストが可能となり、アプリケーションの品質を高めることができます。

  3. クロスブラウザテストの容易さ
    Playwrightは、複数のブラウザでの自動テストをサポートしています。これにより、異なるブラウザでの動作検証が容易になり、互換性の問題を効率的に解決できます。

Storybook とは?

Storybookとは、UIコンポーネントを開発するためのオープンソースツールです。主にReact, Vue, Angularなどの主要なJavaScriptフレームワークに対応しています。Storybookを使うことで、UIコンポーネントの開発、テスト、ドキュメント作成が効率的に行えます。

以下に、Storybookの主な特徴を説明します。

  1. 独立したコンポーネント開発
    Storybookは、個々のUIコンポーネントを独立して開発することができます。これにより、再利用性が高まり、大規模なプロジェクトでも効率的な開発が可能になります。

  2. インタラクティブなドキュメント
    Storybookでは、コンポーネントの見た目や挙動をリアルタイムで確認しながら開発ができます。また、コンポーネントの状態やプロパティを変更して、動作をテストすることができます。

  3. ストーリーと呼ばれる単位でコンポーネントを管理
    ストーリーとは、コンポーネントの特定の状態やデータを表現したものです。ストーリーを作成することで、コンポーネントの複数の状態を簡単に確認・管理できます。

  4. アドオンによる拡張性
    Storybookには多くのアドオンが用意されており、機能を追加することができます。例えば、デザインとの整合性を確認するためのアドオンや、アクセシビリティのチェックができるアドオンなどがあります。

  5. ドキュメント生成
    Storybookを使って、コンポーネントのドキュメントを自動生成できます。これにより、開発者やデザイナーがコンポーネントの使い方や仕様を簡単に理解できるようになります。

Playwright とは?

Playwrightとは、Microsoftが開発したブラウザ自動化テストツールです。Chrome, Firefox, Safariなどの複数のブラウザでのテストが可能であり、JavaScript、TypeScript、Pythonなどの言語に対応しています。Webアプリケーションのエンドツーエンドテスト(E2Eテスト)やシナリオベースのテストを効率的に行うことができます。

以下に、Playwrightの主な特徴を説明します。

  1. クロスブラウザ対応
    Playwrightは、Chrome, Firefox, Safariなどの複数のブラウザでテストを実行できます。

  2. シナリオベースのテスト
    Playwrightを使って、ユーザーの実際の操作に近い状況でのテストが可能です。例えば、フォームの入力やボタンクリックなどの操作を自動化できます。

  3. レスポンシブテスト
    Playwrightでは、異なるデバイスや画面サイズに対応したテストを実行することができます。

  4. ネットワーク操作
    Playwrightを使って、HTTPリクエストやレスポンスを操作・検証できます。これにより、APIとの連携テストなども実現できます。

テスト用サンプルコードの作成

それでは早速StorybookとPlaywrightを組み合わせたテストを作成していきたいと思います。

ここでは、テスト対象のサンプルとして以下のような動作をするScrollTopコンポーネントをテストしていきます。

ScrollTop.tsx
import { useEffect, useState } from "react";
import React from "react";
import './ScrollTop.css';

export const ScrollTop: React.FC = () => {
  const [isShow, setIsShow] = useState(false);
  const [currentPosition, setCurrentPosition] = useState(0);

  const scrollTop = (): void => {
    window.scrollTo({ top: 0, behavior: "smooth" });
  };

  useEffect(() => {
    const scrollEvent = () => {
      // スクロール位置が以前より上の場合はページトップボタンを表示
      // ※ただし、スクロール位置が上の方にある場合は表示しない
      if (window.scrollY < currentPosition && window.scrollY > 100) {
        setIsShow(true);
      } else {
        setIsShow(false);
      }
      // スクロール位置の情報を更新
      setCurrentPosition(window.scrollY);
    };

    window.addEventListener("scroll", scrollEvent);
    return () => window.removeEventListener("scroll", scrollEvent);
  }, [currentPosition]);

  return (
    <>
      <button
        className={isShow ? 'pagetop--show' : 'pagetop--hide'}
        aria-label="ページ上部に戻る"
        onClick={scrollTop}
      >Top</button>
    </>
  );
};
ScrollTop.css
.pagetop--hide {
  color: #fff;
  position: fixed;
  right: 14px;
  bottom: calc(-28px - 44px);
  z-index: 999;
  width: 44px;
  height: 44px;
  background-color: #000;
  border-radius: 8px;
  box-shadow: 0 4px 6px 0 rgb(0 0 0 / 20%);
  opacity: 0.6;
  transition: all 0.3s;
}

.pagetop--show {
  color: #fff;
  position: fixed;
  right: 14px;
  bottom: calc(-28px - 44px);
  z-index: 999;
  width: 44px;
  height: 44px;
  background-color: #000;
  border-radius: 8px;
  box-shadow: 0 4px 6px 0 rgb(0 0 0 / 20%);
  opacity: 0.6;
  transition: all 0.3s;
  bottom: 28px;
}

このコンポーネントは以下のような仕様になっています。

  • 初回ロード時はScrollTopが非表示になっていること
  • 下へスクロールした後に上にスクロールすると、ScrollTopが表示されること
  • ScrollTopが表示された状態で下へスクロールすると、ScrollTopが非表示になること
  • ScrollTopが表示された状態で一番上までスクロールすると、ScrollTopが非表示になること
  • ScrollTopをクリックすると、一番上にスクロールされて、ScrollTopが非表示になること

また、細かいところですが、ScrollTopは非表示をdisplay: noneではなく、positionを変更することによって、下からボタンがシュッと入ってくるようなアニメーションになっているのでテストの際は気をつけなければいけません。

サンプルコードをStorybookに登録

以下のファイルを作成して先ほど実装したサンプルコードをStorybookに登録します。

ScrollTop.stories.tsx
import { ComponentMeta, ComponentStory } from "@storybook/react";
import React from "react";

import { ScrollTop } from "./ScrollTop";

type T = typeof ScrollTop;

const description = ``;

export default {
  component: ScrollTop,
  parameters: {
    docs: {
      description: {
        component: description,
      },
    },
  },
} as ComponentMeta<T>;

export const Default: ComponentStory<T> = () => (
  <div style={{ height: "3000px" }}>
    下へスクロールした後に上へスクロールするとScrollTopボタンが表示されます。
    <ScrollTop />
  </div>
);

これでStorybookでScrollTopコンポーネントの確認をすることができるようになります。
今回は下にスクロールする必要があったので、divタグで囲って大きめの高さを設定しています。
コンポーネントによってはprimary: truesize: 'large'などの引数を渡して想定通りの見た目で実装できているかを確認することもできます。

Storybookに登録したコンポーネントをPlaywrightでテストする

最後にStorybookに登録したコンポーネントをPlaywrightでテストします。
やり方としては、Storybookに登録したコンポーネントにPlaywrightでアクセスして、操作する処理を書いていきます。

この時以下のコマンドを使うと楽にwebの操作を実装できます。
npx playwright codegen "対象のURL"

ScrollTop.spec.tsx
import { expect, test } from "@playwright/test";

// スクロール処理が早すぎるので間にスリープ処理を入れる
const sleep = (msec: number) =>
  new Promise((resolve) => setTimeout(resolve, msec));

test("初回ロード時はScrollTopが非表示になっていること", async ({ page }) => {
  await page.goto(
    "http://localhost:6006/iframe.html?args=&id=scrolltop--default&viewMode=story"
  );

  await expect(
    page.getByRole("button", { name: "ページ上部に戻る" })
  ).not.toBeInViewport();
});

test("下へスクロールした後に上にスクロールすると、ScrollTopが表示されること", async ({
  page,
}) => {
  await page.goto(
    "http://localhost:6006/iframe.html?args=&id=scrolltop--default&viewMode=story"
  );

  await page.evaluate(() => window.scrollTo(0, 2000));
  await sleep(100);
  await page.evaluate(() => window.scrollTo(0, 1000));

  await expect(
    page.getByRole("button", { name: "ページ上部に戻る" })
  ).toBeInViewport();
});

test("ScrollTopが表示された状態で下へスクロールすると、ScrollTopが非表示になること", async ({
  page,
}) => {
  await page.goto(
    "http://localhost:6006/iframe.html?args=&id=scrolltop--default&viewMode=story"
  );

  await page.evaluate(() => window.scrollTo(0, 2000));
  await sleep(100);
  await page.evaluate(() => window.scrollTo(0, 1000));
  await sleep(100);
  await page.evaluate(() => window.scrollTo(0, 2000));

  await expect(
    page.getByRole("button", { name: "ページ上部に戻る" })
  ).not.toBeInViewport();
});

test("ScrollTopが表示された状態で一番上までスクロールすると、ScrollTopが非表示になること", async ({
  page,
}) => {
  await page.goto(
    "http://localhost:6006/iframe.html?args=&id=scrolltop--default&viewMode=story"
  );

  await page.evaluate(() => window.scrollTo(0, 2000));
  await sleep(100);
  await page.evaluate(() => window.scrollTo(0, 1000));
  await sleep(100);
  await page.evaluate(() => window.scrollTo(0, 0));

  await expect(
    page.getByRole("button", { name: "ページ上部に戻る" })
  ).not.toBeInViewport();
});

test("ScrollTopをクリックすると、一番上にスクロールされて、ScrollTopが非表示になること", async ({
  page,
}) => {
  await page.goto(
    "http://localhost:6006/iframe.html?args=&id=scrolltop--default&viewMode=story"
  );

  await page.evaluate(() => window.scrollTo(0, 2000));
  await sleep(100);
  await page.evaluate(() => window.scrollTo(0, 1000));
  await page.getByRole("button", { name: "ページ上部に戻る" }).click();
  await sleep(1000);

  await expect(
    page.getByRole("button", { name: "ページ上部に戻る" })
  ).not.toBeInViewport();
  await expect(await page.evaluate(() => window.scrollY)).toBe(0);
});

これでScrollTopのテストが実装できました。
スクロール処理のテストをするにあたっていくつか注意点があります。

  • Playwrightにscrollイベントが用意されていなかったので、await page.evaluate(() => window.scrollTo(0, 2000));のように自前で実装しました
  • スクロール処理を連続して行うと動作が安定しなかったので間にsleep処理を挟みました
  • ScrollTopコンポーネントは非表示になっているわけではなかったので、toBeVisibleではなくtoBeInViewportで判定を行なっています

テストの実行

ここまできたらあとは実行するだけです。
以下のコマンドでテストが走ります。

npm run test

テスト実行後に、npx playwright show-reportでテスト結果を確認できます。

※まれにテストでコケる時がありますが、再度実行すると通ります、原因は不明です

まとめ

今まではStorybook用のレンダリングとテスト用のレンダリングをそれぞれ別で用意する必要がありましたが、Storybookで登録したカタログをPlaywright側でテストすることによって、Storybook側だけコンポーネントを用意すればよくなったのは大きいです。
また、Storybook単体だと管理の手間が煩わしくて更新されなくなっていってしまいますが、テストと連携するようになると自然とメンテナンスもちゃんとされるようになってくると思います。

LCL Engineers

Discussion