Zenn Tech Blog
📝

ZennのE2Eテスト基盤をリプレイスしました(開発体験向上、CI時間の短縮、Playwright移行)

2024/01/31に公開

はじめに

2023年にZennチームにJoinしたdyoshikawaです。

このたびZennのE2Eテスト基盤をリプレイスしました。このような下回りの改善はユーザへの価値提供との距離が近い機能開発と比べてどうしても後回しになりがちな中、Publication Proという大きなリリースを迎えて少し開発が落ち着いたタイミングであり、E2Eテストを拡充できる土台を整えることで今後より安心して機能を追加していけるようにするために必要だということで実施しました。

各テストを独立実行可能にすることによる開発体験向上CI(GitHub Actions)の実行時間短縮、そして将来を見据えてのCypressからPlaywrightへの移行を行いました。

本記事ではリプレイス前に抱えていた課題、それに対して打ち出した解決方針、そして具体的にどんなことをやったのかを紹介します。

抱えていた課題

前提として、ZennではバックエンドにRuby on Rails、フロントエンドにNext.js、E2EテストにCypressを採用しています。

E2Eテストについて、

  • (特にローカルでの)開発体験
  • CI(GitHub Actions)の実行時間
  • テストの並列実行

が課題のトピックとしてありました。

まず開発体験ですが、手を入れる前は各テストケースが独立して実行可能な状態ではありませんでした。

  1. rails db:seed でDBにテストデータを流し込む
  2. RailsサーバとNext.jsサーバを起動し、テストを実行する

という順序でE2Eテストを実行するようになっていました。

この手順の課題は、最初にE2Eテストに必要なデータのすべてを rails db:seed で生成してから、そのデータをすべてのテストケースが共有利用している点でした。このため、テストケース1つ1つの独立性が低く、どのテストでどのデータを使っているかの把握が難しくなる、書き込みを伴うテストについては連続して2度実行することができず失敗してしまう、テストをやり直す度に rails db:seed からやり直す必要がある、などのペインが生じていました。

上記により、E2EテストはCI(GitHub Actions)上でのみ実行し、ローカルで実行していない状況になっていました。そしてローカルでこまめに実行できないことがE2Eテストの拡充を妨げてしまっていました。

続いてGitHub Actionsの実行時間の課題です。Zennチームでは特定のブランチにPull Requestがつくられるごとに、E2EテストのGitHub Actionsを実行しているのですが、毎回6〜8分ほど度かかっていました。これは他のワークフローと比べて頭ひとつ抜けて長く、他すべてが完了している状況でE2Eテストだけを待つことになり、レビューのやり取りの往復やマージするまでの時間を長くする要因となっていました。

最後にテストの並列実行についてです。Cypressは(私が調べた範囲では)並列実行するには基本的にCypressと契約して課金する必要があり、またCypress Cloudという専用環境を利用する必要があるようです。

この点がハードルとなり、E2Eテストの並列実行が実施できていませんでした。

実際のところ50〜60件ほどしかテストがなく、並列実行が必要な状況ではなかったのですが、今後テストを増やしていくとなると必要になりますし、チーム内でも「並列実行はやっぱりできたほうが良いよね」という話をしていました。

開発体験向上とCI高速化、そしてPlaywright移行へ

上記の課題を解決するために以下を実施することにしました。

まず開発体験の課題については、各テストを独立して実行可能なつくりに変更することにしました。必要なデータはテスト毎に用意することで、いつどこで実行しても同じ結果になることを目指します。ローカルで特定のテストケースだけを繰り返し実行することができるようなれば、「E2Eテストにあまり触りたくない」という気持ちは払拭されるはずと思いました。

CIの高速化については、E2Eテストの次に時間がかかっているワークフローはRSpecで3~4分程度だったので、ひとまずこの時間までにおさまることを目指すことにしました。

最後の並列実行については、思い切ってPlaywrightに移行することにしました。移行に踏み切ったのはまだまだテストケースが少ない段階だったことが大きいです。Zennチームは少人数なこともあり、E2Eコードベースが非常に大きくなっていたらマンパワー的に厳しかっただろうと思います。

Cypressのままでも以下のようにCypress Cloudに依存しない方法を採れるようでしたが、Playwrightの本体機能のみでローカルでもCI上でもシンプルに並列実行を実現できる点に魅力を感じました。

また、ChromeだけでなくWebkit(Safari)を簡単にテストできる[1]点、Cypressのトレードオフに縛られなくなるので、将来的にテストケースの選択肢を広く取れる点も良いと思いました。

ただ後述しますが、移行する中でCypressの方が優れている点も多々あると感じました。

やったこと

テストを独立して実行可能にする

ユーザ名や記事のSlugなど一意性が求められる値についてはテストごとにランダムな値を生成して割り当てることで、各ケースを独立して実行可能にしました。

最初はテストデータをUI自動操作で作成していました。これによりテストデータの作成過程自体もテストできて一石二鳥と思っていたのですが、実際やると想定以上に実行時間が伸びてしまったのと、1つのテストケースでUI操作が多くなるにつれFlakyになる(ために失敗する)確率が上がるように感じたため、やはりテストしたい対象の操作と操作の前提となるデータの作成は分離した方が良いと思い直し、テストデータ作成APIを用意しそこから必要なデータを投入するようにしました。

以下はテストの一例です。

test('記事を開いて閲覧できること', async ({ page }) => {
  // 著者と記事を作成する
  const { username: authorUsername } = await createUserApi({ page });
  const title = randomHiragana(10);
  const body = randomHiragana(1000);
  const { articleSlug } = await createArticleApi({
    username: authorUsername,
    title
    body,
  });

  // 記事が開けること
  await page.goto(`/${authorUsername}/articles/${articleSlug}`);
  await expect(page.getByText(title)).toHaveCount(1);
  await expect(page.getByText(body)).toHaveCount(1);
});

これにより後述の並列実行も容易になります。ユニーク制約がある値の衝突についてはテストなので厳密に検証していませんが、無視できる確率と思います。

Next.jsを開発モードで起動してテストを行う

従来のフローではE2Eテストの実行前にプロダクションビルドを行っていました。

もちろんプロダクションビルドを行うメリットはあると思うのですが、ビルドがCIの実行時間を伸ばしてしまっていたのと、ローカル開発においても逐一ビルドするのではフロントエンドを細かく修正して実行し直すというループを素早く回しづらいというデメリットがあったので、今回は手軽さと素早さを優先し、Next.jsを開発モードで立ち上げてテストするようにしました。

Playwrightでは以下のようにテスト開始時に自動的にサーバを起動する設定ができます。

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // 省略

  webServer: {
    command: 'yarn dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

reuseExistingServer を設定することで、すでに別でサーバが立ち上がっている場合(ローカル開発だとよくあると思います)はそれを使い回すようにしてくれます。

TestConfig | Playwright
開発サーバーとPlaywrightで立ち上げる開発サーバーのポートが被らないようにする

node_modulesをキャッシュする

Zennではパッケージマネージャにyarnを使っています。

CI上で yarn install に約90秒とけっこう時間がかかっていたため、 node_modules をキャッシュすることにしました。

GitHub Actionsでactions/setup-nodeだけでnode_modulesをキャッシュできるのか試してみた | DevelopersIO

actions/setup-nodecache オプションがあるのですが、どうやら node_modules のキャッシュとその復元はしてくれないようなので、上記を参考に actions/cache でキャッシュを設定します。

.github/workflows/e2e.yml
      - name: Restore packages
        uses: actions/cache@v3
        id: node_modules_cache_id
        env:
          cache-name: cache-node-modules
        with:
          path: '**/node_modules'
          key: frontend-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('frontend/yarn.lock') }}

      - name: Install packages
        if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
        run: yarn install --frozen-lockfile

この対応によりキャッシュヒット時はリストアに約10秒ということで大幅に短縮できました。

テストを並列実行する

Playwrightでは workers を指定することで並列数を設定できます。

GitHub Actionsのデフォルトのインスタンスの性能では、あまり並列数を上げることはできませんでした。そのため、Github hosted larger runnerを設定しました。

GitHub Actions で 大規模ランナー(GitHub-hosted larger runners)が GA となりました | DevelopersIO

  1. GitHub OrganizationのSettings
  2. 左メニューActions

より設定が可能です。

この並列数だとどのインスタンスサイズがちょうど良いのか?の調査には、workflow-telemetry-actionというActionが便利でした。

https://twitter.com/sasasin_net/status/1704418980197540349

GitHubActions CPU使用率

このようにCPU使用率がわかります。

それなりに並列数を持たせ、CPUリソースは余り過ぎずかといって100%に張りつかないバランスで調整した結果、

  • CPU 8コア
  • Playwright並列数 8

としました(ただメモリはすごく余裕ができてしまいもったいない感じがしました)。

playwright.config.ts
  // CIでは8並列、ローカルでは4並列で実行する
  workers: process.env.CI ? 8 : 4,

自分の端末では4並列くらいが重すぎないと感じたので上記のようにしました。各開発メンバーごとにコマンド実行時の --workers オプションで上書きも可能です。

Runner Groupを zenn-e2e Runnerの名前を zenn-e2e-8cores としたので、GitHub Actions Workflowファイルは以下のように設定しました。

.github/workflows/e2e.yml
jobs:
  e2e:
    timeout-minutes: 15
    runs-on:
      group: zenn-e2e
      labels: zenn-e2e-8cores

(お金はかかりますが)インスタンスを大きくするだけでかなりのところまで並列度を上げられるという道筋をつけられたのは良かったかと思います。

PlaywrightのTips

Playwrightでテスト実装していく上で便利だったもの、また逆に苦戦したものを紹介します。

特定のテストだけ実行する

実装中のテストやFlaky(たまに落ちる)なテストを繰り返し実行したいことがよくあります。そういう時は test.only が便利です。

-test('なにかのテスト', async ({ page }) => {/* ... */})
+test.only('なにかのテスト', async ({ page }) => {/* ... */})

playwright test path/to/file.test.ts:10 のようにコマンドからファイルと行数を指定しての実行もできるのですが、個人的には編集真っ最中のテストファイルだけを見て変更すれば良く視点移動が少ないという点で上記の方が使いやすく感じます。

.only を消し忘れた際はエラーにしてくれるオプションもあるので安心です。

playwright.config.ts
// CIではonlyがあったら失敗させる
forbidOnly: !!process.env.CI,

テストを止めてデバッグする

テストケースを途中で止めてデバッグするには page.pause() が便利です。

playwright test --headed でブラウザを表示させながら実行します。

test('記事を開いて閲覧できること', async ({ page }) => {
  // 著者と記事を作成する
  const { username: authorUsername } = await createUserApi({ page });
  const title = randomHiragana(10);
  const body = randomHiragana(1000);
  const { articleSlug } = await createArticleApi({
    username: authorUsername,
    title
    body,
  });

  // 記事が開けること
  await page.goto(`/${authorUsername}/articles/${articleSlug}`);
+ await page.pause(); // ここで止める
  await expect(page.getByText(title)).toHaveCount(1);
  await expect(page.getByText(body)).toHaveCount(1);
});

page.pause() 挿入箇所でテストが止まります。失敗する expect() の直前に入れたりすると調査が捗ります。

要素のつかまえ方を調べる

page.locator()page.get*() で対象の要素のつかまえ方がわからないときのテクニックです。

page.pause() で止めた際に、画像のようなPlaywrightのウィンドウが出てきます。

要素のつかまえ方1

赤枠のボタンを押して要素を選択することで、その要素をつかまえるコードを生成してくれます。

要素のつかまえ方2

この場合だと、たとえば

// 要素をクリックする場合
await page.getByRole('link', { name: 'Zenn | エンジニアのための情報共有コミュニティ' }).click()

// 要素が存在することを検証する場合
await expect(page.getByRole('link', { name: 'Zenn | エンジニアのための情報共有コミュニティ' })).toHaveCount(1)

のように使うことができます。

Google Analyticsをモックする

もともとCypressにGoogle Analyticsの gtag() 関数をモックして呼び出しするテストがあり、それを移植するのにやや苦労しました。

Mock browser APIs | Playwright

公式ドキュメントの上記を参考に以下のようなコードを書きました。

/**
 * `window.gtag()` をモックする
 *  `gtag()` が呼び出された回数と引数をアサーションできるように `log` 変数に記録して返す
 * @see https://playwright.dev/docs/mock-browser-apis
 */
const mockGtag = async (page: Page): Promise<object[]> => {
  const log: object[] = [];
  await page.exposeFunction('logCall', (msg: any) => log.push(msg));
  await page.addInitScript(() => {
    const mock = () => {
      if (window.gtag == null) {
        setTimeout(mock, 300);
      }

      window.gtag = (...args: any) => {
        // @ts-expect-error
        logCall({ name: 'gtag', args });
      };
      window.mockedGtag = true;
    };
    setTimeout(mock, 300);
  });
  return log;
};

// 使い方
const log = await mockGtag(page);
await page.waitForFunction(() => window.mockedGtag);
expect(log).toMatchObject(/* 省略 */)

Playwrightの page.addInitScript() はかなりなんでもできるのですが、そのぶんAPIとしてはややプリミティブでありモックを自分でゴリゴリと実装する必要があります。

この点、Cypress.stubCypress.sinonが提供されており、これらを利用してより手軽に可読性高くモックが書けるのはCypressのメリットだと思いました。

[Feature] Time/Date emulation via e.g. a `clock()` primitive · Issue #6347 · microsoft/playwright

上記のように page.addInitScript() でもsinonを注入するサンプルもあったりするのですが、あまり独自の仕組みを作り込みすぎるとそれはそれで負債になることもあるので悩ましいです。

XMLをパースする

RSSフィードのテストを書く際にXMLをパースする必要がありました。Playwrightではfast-xml-parserパッケージをインストールして以下のように書きました。

import { XMLParser } from 'fast-xml-parser';

test('RSSフィードにアクセスできる', async ({ page }) => {
  const res = await page.goto('/feed');
  const parser = new XMLParser();
  const xmlStr = (await res?.text()) ?? '';
  expect(xmlStr).toMatch(
    new RegExp(
      '<atom:link href="http:\\/\\/localhost:[0-9]+\\/feed" rel="self" type="application\\/rss\\+xml"\\/>'
    )
  );
  const xml = parser.parse(xmlStr); // XMLパース
  expect(xml).toHaveProperty('rss.channel.title', 'Zennのトレンド');
});

この点、CypressではNode.jsではなくブラウザでテストが実行されます。

Trade-offs | Cypress Documentation

But what this also means is that your test code is being evaluated inside the browser. Test code is not evaluated in Node, or any other server side language. The only language we will ever support is the language of the web: JavaScript.

そのため、ブラウザ標準搭載のDOMParserを使うことができます。実際、移行前はDOMParserを使ったテストが書かれていました。

文字列からXMLDocumentを取得するにはDOMParserのparseFromString()を使います。モダンブラウザで標準でサポートされているためライブラリ等は不要です。

文字列からXMLDocumentを取得するにはDOMParserのparseFromString()を使います。モダンブラウザで標準でサポートされているためライブラリ等は不要です。

もちろんDOMParserに限った話ではなく、クライアントサイドJSのAPIを使うことができるのはCypressのメリットの1つと思います[2]

失敗時にレポートをダウンロードできるようにする

CI上でテストが失敗した際に原因を調査できるようにしておくのは重要です。

GitHub Actions上で実行したPlaywrightのレポートを見る手順

上の記事を参考に、GitHub Actionsでのテスト失敗時にPlaywrightのレポートをダウンロードできるようにします。

reporter オプションに html を指定することで ./playwright-report が出力されます。

yarn run playwright test --reporter html

ただ手元の実行では毎回リッチなレポートが欲しいわけではないので、デフォルトは list にしておきます。

playwright.config.ts
reporter: 'list',

on-first-retry を指定することで、失敗時にのみレポートを出力するようにしました。

playwright.config.ts
use: {
  trace: process.env.CI ? 'on-first-retry' : 'on',
  video: process.env.CI ? 'on-first-retry' : 'on',
},

あとはactions/upload-artifactを使ってアップロードするようにします。

.github/workflows/e2e.yml
      # 失敗したときはレポートを保存する
      # ダウンロードしてzip解凍し、 `yarn playwright show-report ~/Downloads/playwright-report` で確認する
      # @see https://zenn.dev/leaner_dev/articles/20220825-show-playwright-report
      - name: Upload report on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: frontend/playwright-report
          retention-days: 1

最終的な設定ファイル

最終的にできあがった設定ファイルです。

playwright.config.ts

playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: 'playwright',

  // ファイルが同じテストケースでも関係なくすべて並列実行する
  fullyParallel: true,

  // CIでは8並列、ローカルでは4並列で実行する
  // NOTE: デフォルト(undefined)は論理的CPUコア数の半分の数で並列実行される
  workers: process.env.CI ? 8 : 4,

  // CIではonlyがあったら失敗させる
  forbidOnly: !!process.env.CI,

  // リトライ回数
  retries: process.env.CI ? 2 : 1,

  reporter: 'list',

  use: {
    baseURL: 'http://localhost:3000',
    trace: process.env.CI ? 'on-first-retry' : 'on',
    video: process.env.CI ? 'on-first-retry' : 'on',
  },

  // 40秒でタイムアウトする
  timeout: 40000,

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],

  webServer: {
    command: 'yarn dev',
    url: 'http://localhost:3000',
    // ローカルの場合はすでに起動されているサーバを再利用する
    reuseExistingServer: !process.env.CI,
  },
});

GitHub Actions Workflowファイル

こちらは実際に使っているものと全く同じ内容ではありませんが、サンプル的に紹介します。

.github/workflows/e2e.yml
# playwright を使ったE2Eテスト
name: Playwright E2E test

on:
  pull_request:
    branches:
      - main

jobs:
  e2e:
    timeout-minutes: 15
    runs-on:
      group: zenn-e2e
      labels: zenn-e2e-8cores

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # Railsのセットアップ&ローカルサーバ起動(記載省略)

      - name: Set up Nodejs
        uses: actions/setup-node@v3
        with:
          node-version: 18.12

      - name: Restore packages
        uses: actions/cache@v3
        id: node_modules_cache_id
        env:
          cache-name: cache-node-modules
        with:
          path: '**/node_modules'
          key: frontend-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('frontend/yarn.lock') }}

      - name: Install packages
        if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
        run: yarn install --frozen-lockfile
      
      - name: Install playwright
        run: yarn run playwright install chromium # いまのところfirefoxとsafariは使わないのでchromiumのみインストールする

      - name: Run test
        run: yarn run playwright test --reporter html

      # 失敗したときはレポートを保存する
      # ダウンロードしてzip解凍し、 `yarn playwright show-report ~/Downloads/playwright-report` で確認する
      # @see https://zenn.dev/leaner_dev/articles/20220825-show-playwright-report
      - name: Upload report on failure
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report
          retention-days: 1

リプレイスの結果得られたこと

以上のリプレイス作業の結果、以下が得られたと考えています。

まず、ケースごとにテストデータが独立したことで開発体験が改善されました。手元で特定のテストケースをデバッグすることが容易になり、繰り返し実行することもできるようになりました。これにより「E2Eテストをもっと書いていこう」という機運が高まることを期待しています。

CI(GitHub Actions)については、時間のかかる処理をチューニングしたことで6〜8分から2〜3分程度に実行時間が短縮され、目標としていたRSpecのWorkflowと同程度かそれ以下になったため「E2Eテストのせいで待たされる」という状況は大きく減る見込みです。

また、Playwrightに移行したことでローカルでもGitHub Actions上でも並列実行が容易に実現できるようになりました。これからE2Eテストケースの数がどんどん増えていっても実行時間の増加はゆるやかにすることができるでしょう。特にGitHub Actionsに関しては、課金額の問題があるので無条件とはいかないものの、インスタンスを大きくさえすればかなりのところまで並列度を上げられるという選択肢を得たということの安心感が得られたのが良かったと思います。

おわりに

以上、ZennのE2Eテスト基盤のリプレイスについて紹介しました。

ここでCypressとPlaywrightの速度比較について気になる方がいると思うので軽く触れておきたいと思います(ブログ記事やX(Twitter)においてCypressよりPlaywrightの方が速いという言及をいくつか見ました)。

まず、今回は単純なツール移行ではなく、各テストごとにデータ生成する、モックやアサーションする方法を変える、一部ケースについてはを削るなど変更が多岐に渡っているので条件を揃えての実測比較はできませんでした。

その上で、スペック的にみても両ツール一長一短がありどちらが速いかを結論づけることは難しいと感じました。

完全無料かつ本体の機能のみで並列実行可能であることはPlaywrightが有利だと思います。一方で、直列に実行する場合はCypressの方が速いのではないかと感じました。

CypressはPlaywrightに負けてない #e2e - Qiita

テスト実行におけるオーバーヘッドがほぼない
Cypressで開発するときは常にGUIランナー(ブラウザ)を起動しているので、ブラウザを起動する・閉じるといったオーバーヘッドが発生しません。

Playwrightはテスト毎にブラウザを閉じたり開いたりするのに対し、Cypressはそれがないため直列に実行する場合はCypressに有利と思います。特にひとつひとつのテスト時間が短いかつケースが多い場合はこのオーバーヘッドが相対的に大きな差として表れてくるはずです。

そのため、どちらが速いかは利用者の条件設定次第と思いました。

最後に、今後の課題についてです。

まだ一部Flakyなテストがあります。そのため、開発の合間をぬって改善していく必要があります。

また、今後GitHub Actions上でさらに並列実行数を増やす場合、本当にインスタンスサイズの拡張の方向で良いのか検討したいと思います。現状メモリがかなり余っているのが気になるためです。

イチ押し。Playwrightの快適機能 | フューチャー技術ブログ

テスト全体を任意の数nに分離し、その1つ目のテストセットを実行したい場合は、
npx playwright test --shard=1/n
のように実行してあげることで実現できます。

shard オプションを使うことで、GitHub Actionsの複数環境での並列実行が実現できそうです。トータルの課金額、管理の容易さなど比較衡量して考えたいです。

あとは何より現状E2Eテストでカバーできていない範囲が多い状態なので、計画的にカバー範囲を広げていきたいと思います。

Zennの品質を支えるためにE2Eテストは非常に重要な役割と果たしていくものになると考えているため、今後も改善を続けていきます。

参考

脚注
  1. CypressもWebkitをExperimentalでサポートしています。一部Playwrightに依存することで実現しているようです。 ↩︎

  2. これによりトレードオフが生じているので、一長一短の特徴にはなります。 ↩︎

Zenn Tech Blog
Zenn Tech Blog

Discussion