🔖

@storybook/test-runner を playwright server と実行し VRT する

2023/12/23に公開

はじめに

@storybook/test-runner は jest-transformer で storybook の story をテストケースに変換し、playwright で各 story を巡回してテストを実行するソフトウェアです。

@storybook/addon-interactions と story 定義の play メソッドによって story に対してインタラクションを行ったり任意の評価を実行することができます。

@storybook/test-runner は一連の処理の中で実行されるフックを定義することができます。story を訪れた後のフックでスナップショットを取得し、既存のスナップショットとの比較を行うことで VRT が実現できます。
@storybook/test-runner での VRT については @storybook/test-runner の README に記載があるのでそちらを参照ください。

この記事では、上記の VRT の実行に playwright server を使う方法について説明します。
以降では、まず playwright server 自体に軽く触れます。そして、そもそもなぜ、playwrigt server を使いたいのかという動機を説明します。その後具体的な利用方法を示します。

『あーはいはい。playwright serverね。あれね。ちょうど私も@storybook/test-runner での VRTにそれ使いたかったんだわー。マジでー。』という方は実装例のリポジトリを参照すれば十分だと思うのでそちらを参照ください。

https://github.com/sterashima78/storybook-test-runner-with-playwright-server-example

playwright server について

playwright は Web ブラウザをプログラムから操作することを助けるライブラリです。主にテストなどに利用されます。
playwrigt はクライアント・サーバー型の構成になっており、サーバーのみを起動させることができます。この、playwright のサーバーを記事冒頭から playwright server と呼称しています。

クライアントとサーバーは websocket で通信を行うため、ブラウザに指示を出す側のプログラムを実行するクライアントと、実際にブラウザが動作するサーバーは別の環境に分けることができます。

playwright には run-server というサブコマンドがあり、これがサーバーを起動するものです。
また、このコマンドは Web ドキュメントに記載がありません。

なぜ、playwright server を使いたいか

前節の説明からわかると思いますが、この記事はテストの実行と、テストの中で行われるブラウジングの環境を分けたいという動機から生まれています。

VRT でブラウジング部分を別環境で行いたい理由は、イメージスナップショットが環境依存で変化することを防ぐためです。

VRT を開発に組み込むに当たって、CI 環境のみで実行する方針があります。この場合テストは CI 環境でのみ行われるため実行環境の差異を意識する必要がありません。
VRT は視覚的な退行を検知するためのものなので、CI で確認できれば OK で、手元でのセットアップが不要で楽になるというのは理にかなっていると思います。

一方で、私はテストの実行はどこでもできるべきだという価値観を持っています。
テストは実装に対するフィードバックを得る手段なので、このフィードバックが CI 上でしか得られないとフィードバックループが長くなり開発が遅くなることに繋がりかねません。

例えば私は以下のようにUIコンポーネントの実装を進めることがあります。

  1. デザインに合わせたマークアップとスタイリングを実装する
  2. 各状態に対応する Story を定義する
    • 一旦コンポーネントの状態は外部から渡される状態で表現できるようにして内部のロジックはできるだけ作りこまない
  3. スナップショットを撮る
  4. ロジックを実装する
    • 必要に応じて外部から渡されてた状態を内部状態にする
    • 状態変化をさせるためのインタラクションを play で実装する
  5. VRT を実行して見た目が壊れてないか確認する
  6. 完了まで 4 と 5 を繰り返す

このように普通に開発を進めるために VRT を使おうと思うとローカルで VRT が実行されてほしいです。しかし、複数の開発者が存在すると、それぞれの手元の環境に違いで VRT に失敗する可能性があります。

そこで、playwright server を docker で実行することにします。これによって開発者の環境や CI 環境に差があっても VRT を実行するときのブラウジング環境が変わらないのでテストが安定します。

実現方法

前置きが長くなりましたが、以下で実現方法を説明します。

まず、playwright server を実行する環境を定義します。docker で実行するので docker-compose.yaml に記載することにします。

ちなみに storybook のビルドなどはこのサーバー環境では実行しません。
最近はいくらか改善していると思いますが、環境によっては docker 上でのビルドはパフォーマンスが著しく劣化することがあるためです。
これを回避するために、ブラウジング環境以外の全ては普段の開発環境で行います。
これは、開発方法を大きく変えずに導入しやすいというメリットにもなります。

以下のようになります。

version: "3"
services:
  playwright:
    image: mcr.microsoft.com/playwright:v1.40.1
    ports:
      - 3000:3000
    entrypoint: npx playwright run-server --port 3000
    extra_hosts:
      - "host.docker.internal:host-gateway"

extra_hosts はコンテナ内の名前解決レコードを追加するものです。追加しているのは host.docker.internal という名前で、ホスト環境 (Docker を実行している側) を参照できるようにするものです。
host.docker.internal という名前は慣習でよく使われているらしいものなので、任意の名前で問題ありません。

entrypoint では playwright のサーバーを実行しています。ポートの 3000 番でクライアントからの通信を受け付けるようにしているので、ports の設定でホストの 3000 番にバインドさせています。

次にクライアント側です。@storybook/test-runner の playwright が docker の playwright server とお話する必要があるのでその設定です。

test-runner-jest.config.js を以下のようにします。

import { getJestConfig } from "@storybook/test-runner";

/**
 * @type {import('@jest/types').Config.InitialOptions}
 */
export default {
  ...getJestConfig(),
  testEnvironmentOptions: {
    'jest-playwright': {
      connectOptions: {
        chromium: {
          wsEndpoint: 'ws://127.0.0.1:3000',
        },
      },
    },
  },
};

@storybook/test-runner は jest を用いているのですが、 jest-playwright を使って playwright と協調させています。jest-playwright の設定で、playwright クライアントのお話しあいてを ws://127.0.0.1:3000 にしています。ローカルホストの 3000 番は docker 内の playwright server につながります。

あとは、クライアント側で storybook を例えば localhost:6006 で起動し、test-runner の実行先の URL を host.docker.internal:6006 にすれば良さそうですが、これだとうまくいきません。

というのも、実行先の URL を host.docker.internal:6006 に設定した時 @storybook/test-runner は以下のように動きます。

  1. クライアント側から host.docker.internal:6006 にアクセスし、storybook が起動しているか確かめる
  2. playwright を実行し サーバー側 からhost.docker.internal:6006 にアクセスしテストを実行する

なんでわざわざこんなことをしているかはわかりませんが、とにかくこう動くので、クライアント側からは host.docker.internal が解決できず 1 で失敗します。

これを解決するために、@storybook/test-runner での playwright の挙動を変更させます。
これは @storybook/test-runner の prepare というフックを変更することで実現します。

prepare フックについては README を参照してください。

.storybook/test-runner.ts で prepare を以下のように定義することで実現できます。

export default {
  // デフォルト prepare をオーバーライドする
  // https://github.com/storybookjs/test-runner#prepare
  async prepare({ page, browserContext, testRunnerConfig }) {
    // コンテナから host へ 
    const targetURL = 'http://host.docker.internal:6006';
    const iframeURL = new URL('iframe.html', targetURL).toString();

    if (testRunnerConfig?.getHttpHeaders) {
      const headers = await testRunnerConfig.getHttpHeaders(iframeURL);
      await browserContext.setExtraHTTPHeaders(headers);
    }

    await page.goto(iframeURL, { waitUntil: 'load' }).catch((err) => {
      if (err.message?.includes('ERR_CONNECTION_REFUSED')) {
        const errorMessage = `Could not access the Storybook instance at ${targetURL}. Are you sure it's running?\n\n${err.message}`;
        throw new Error(errorMessage);
      }

      throw err;
    });
  },
  async postVisit(page, context) {
    // snapshot の取得など
  },
};

おわりに

prepare のフックが最初からあったのかは覚えてないのですが、prepare フックの存在を認知するまでは、この挙動を解決するためにずっと storybook/test-runner には pnpm の機能でパッチを当てて使っていました。
結構めんどくさかったので、パッチ当てなくていいことがわかってよかったです。

Discussion