Playwright と Artillery によるパフォーマンステスト

# 要約

- DevOps や SRE を実践する人向けに、Playwright による E2E テストを Artillery の負荷掛けシナリオとして利用する手法についてハンズオンを交えて紹介します。
- 課題としては、Playwright で自動生成したスクリプトを手直ししないと難しいパターンがあることを挙げています。
- その他、Tips もあります。

# はじめに

[NSSOL Advent Calendar 2022](https://qiita.com/advent-calendar/2022/nssol) の 13 日目にも[投稿](https://qiita.com/BlueTofuchic/items/57e54875d6f45eccb28c)しました、とうふです。
先の記事で軽く説明した通り、私がいま参画している案件では
> E2E テスト用のスクリプトを流用したパフォーマンステスト

を行なっています。
本記事では、Playwright で作成した E2E テストスクリプトを使って負荷掛けを行うことでパフォーマンステストを実施する手法について紹介します。

# 前提知識

## Playwright とは

[Playwright](https://playwright.dev/) とは、Microsoft が開発している E2E テストツールの OSS です。
E2E テストツールとしては、[Cypress](https://www.cypress.io/) が有名でデファクトスタンダードだと思いますが、インストールに苦労する[^1]他、テストスクリプトのコーディングが大変という印象です。

[^1]: 「🔍WSL2 cypress install」とかでググると分かりますが、Node.js のパッケージをインストールするだけでは環境構築が整わない場合があるんですよね。 そのせいか、CI/CD パイプラインで Cypress がうまく使えたり使えなかったりします。

対して Playwright は、Node.js さえあれば動きますし、何よりも `playwright codegen` コマンドによるテストスクリプトの自動生成が行えるのが強みだと思っています。

## Artillery とは

[Artillery](https://www.artillery.io/) は、DevOps や SRE にフォーカスを当てた Node.js 製の負荷掛けツールです。

負荷掛けツールと言えば JMeter が有名で、こちらも Cypress 同様デファクトスタンダードだと思います。
しかし DevOps や SRE の原則通りに、機能開発を担当する開発者が非機能担保に対してまで責任を持ったり、ソフトウェアで運用の課題を解決するような体制を取る状況を想像してみてください。
領域毎に扱う技術が増えれば増えるほど、難解であればあるほど、結局サイロ化が解消されないという結果になり得ます。

また、従来の負荷掛けツールだと API 毎にどんなリクエストを送るのかを記述しシナリオとして実行します。
たとえば SPA で、バックエンド API を簡単には呼び出せないようなウェブサイトに対して負荷をかける場合はどうすれば良いでしょうか。

負荷掛けスクリプトの学習コスト削減やブラウザベースでの負荷掛けを謳っているのが、この Artillery です。
具体的には YAML で負荷掛けの設定やシナリオを表現することになるのですが、他の負荷掛けツール同様 API ベースのシナリオを書く以外に、Playwright の E2E スクリプトを流用できるのが大変素晴らしいポイントです。
当然、CI/CD パイプラインにパフォーマンステストを組み込むということもできます。

ちなみに和訳は「大砲」です。
負荷掛けツールには他に [Gatling](https://gatling.io/) もありますし、火器の名前を採るプロダクトが他にもありそうですね。
ご存じの方がいたらぜひ教えてください。

# ハンズオン

## 概要

前回の投稿にもあるように、せっかく[柑これ](https://tofuchic.github.io/kancolle/)というウェブサイトをリリースしているので、そこに対して負荷をかけるような真似をしていきたいと思います。

ハンズオンの内容は以下のリポジトリに格納しています。
https://github.com/tofuchic/kancolle-perf-testing

## 環境構築

### Playwright

まずは Playwright を実行できる環境を用意しましょう。`yarn` は実行できるようにしておいてください。

```bash:私の実行環境です
$ yarn -v
1.22.18
$ node -v
v16.15.0
```

```bash
mkdir ~/kancolle-perf-testing
cd ~/kancolle-perf-testing
yarn create playwright
```

いくつか質問に答えるだけで良い感じのディレクトリが作成されます。
Javascript を選択して、あとはデフォルトのままにします。後から次のコマンドでインストールすることもできますが、ブラウザテストに必要なパッケージのインストールもここで行えます。

```bash
yarn playwright install
sudo yarn playwright install-deps
```

次のコマンドが実行できて、ブラウザが立ち上がれば OK です。「Ctrl + C」で強制終了してください。

```bash
yarn playwright -V
yarn playwright codegen https://tofuchic.github.io/kancolle/
```

### Artillery

続いて Artillery が実行できるようにします。

```bash
yarn add -D artillery
```

次のコマンドが動いて恐竜の絵が表示されれば OK です。

```bash
yarn artillery --help
yarn artillery dino
```

これだけでは Artillery は API テストしか行えず、Playwright の E2E スクリプトを流用できません。そのため [Playwright プラグイン](https://github.com/artilleryio/artillery-engine-playwright)もインストールします。

```bash
yarn add -D artillery-engine-playwright
```

## テストスクリプトの作成

さて、それではサンプルのスクリプトを作成しましょう。

```bash
yarn playwright codegen https://tofuchic.github.io/kancolle/
```

適当に画面を操作すると次のようなコードが自動で生成できると思います。
`tests` ディレクトリに置きましょう。
間違ってもユーザーIDとパスワードを git に含めないように気を付けてくださいね。

```tests/playwright.spec.js
import { test, expect } from '@playwright/test';

test('test', async ({ page }) => {
  await page.goto('https://tofuchic.github.io/kancolle/');
  await page.getByRole('button', { name: 'open drawer' }).click();
  await page.getByRole('link', { name: 'かんみのみかん' }).click();
  await page.locator('.MuiBackdrop-root').click();
  await page.getByRole('link', { name: '2022年11月号' }).click();
  await page.getByRole('link', { name: 'ログインしてみかんをレビューしよう' }).click();
  await page.getByRole('button', { name: 'Twitterでログイン' }).click();
  await page.getByRole('heading', { name: 'Authorize mikancolle to access your account?' }).click();
  await page.getByRole('button', { name: 'Sign In' }).click();
  await page.getByLabel('Phone, email, or username').click();
  await page.getByLabel('Phone, email, or username').fill('yourTwitterAccountName');
  await page.getByRole('button', { name: 'Next' }).click();
  await page.getByRole('textbox', { name: 'Password Reveal password' }).fill('yourTwitterAccountPassword');
  await page.getByTestId('LoginForm_Login_Button').click();
  await page.getByRole('button', { name: 'ログアウト' }).click();
});
```

次のコマンドで、`playwright.config.js` 通りのブラウザでテストが行われます。

```bash
yarn playwright test tests/playwright.spec.js
```

デフォルトだと webkit・choromium・firefox と、3 つのブラウザによってテストが行われるのですが、成功しないこともあるんですね...。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105953/d163b5f5-15ea-50a3-7da8-a54f67800054.png)

このスクリプトはそのままでは Artillery で実行できません。そのため [Playwright プラグイン（2022 年 12 月現在最新の v0.2.1 ）の README](https://github.com/artilleryio/artillery-engine-playwright/tree/v0.2.1) にある `flow.js` に倣って少し改修する必要があります。

```tests/playwright.js
module.exports = { helloKancolle };

async function helloKancolle(page) {
  await page.goto('https://tofuchic.github.io/kancolle/');
  await page.getByRole('button', { name: 'open drawer' }).click();
  await page.getByRole('link', { name: 'かんみのみかん' }).click();
  await page.locator('.MuiBackdrop-root').click();
  await page.getByRole('link', { name: '2022年11月号' }).click();
  await page.getByRole('link', { name: 'ログインしてみかんをレビューしよう' }).click();
  await page.getByRole('button', { name: 'Twitterでログイン' }).click();
  await page.getByRole('heading', { name: 'Authorize mikancolle to access your account?' }).click();
  await page.getByRole('button', { name: 'Sign In' }).click();
  await page.getByLabel('Phone, email, or username').click();
  await page.getByLabel('Phone, email, or username').fill('yourTwitterAccountName');
  await page.getByRole('button', { name: 'Next' }).click();
  await page.getByRole('textbox', { name: 'Password Reveal password' }).fill('yourTwitterAccountPassword');
  await page.getByTestId('LoginForm_Login_Button').click();
  await page.getByRole('button', { name: 'ログアウト' }).click();
}
```

これで Artillery で実行する負荷掛けスクリプトが半分完成しました。

## 負荷の設定

続いて、Artillery を実行するための YAML を作成します。

```tests/artillery.yml
config:
  target: https://tofuchic.github.io/kancolle # Playwright engine doesn't use this target
  # Enable the Playwright engine:
  engines:
    playwright: {}
  processor: "./playwright.js"
scenarios:
  - engine: playwright
    flowFunction: "helloKancolle"
    flow: []
```

これでうまく行けばよかったんですが、失敗しますね...。

```bash: 失敗
$ yarn artillery run tests/artillery.yml

（中略）
⠋ locator.click: Timeout 30000ms exceeded.
=========================== logs ===========================
waiting for getByRole('heading', { name: 'Authorize mikancolle to access your account?' })
============================================================
    at helloKancolle (/home/tofuchic/kancolle-perf-testing/tests/playwright.js:15:93)
    at async Array.scenario (/home/tofuchic/kancolle-perf-testing/node_modules/artillery-engine-playwright/index.js:139:9) {
  name: 'TimeoutError'
}
```

リダイレクトがうまく動作していないようです。
`playwright.js` を修正してみたのですが、ここまでで断念しました。

```diff_javascript:tests/playwright.js
  await page.getByRole('button', { name: 'Twitterでログイン' }).click();
+ const response1 = await page.waitForNavigation();
+ await page.goto(response1._request._initializer.url)
+ const response2 = await page.waitForNavigation();
+ await page.goto(response2._request._initializer.url)
  await page.getByRole('heading', { name: 'Authorize mikancolle to access your account?' }).click();
```

ヘッドレスモードを諦めたところ、うまく動作することを確認できました。

```diff_yaml:tests/artillery.yml
  engines:
    playwright:
+     launchOptions:
+       headless: false
  processor: "./playwright.js"
```

```bash: 成功
$ yarn artillery run tests/artillery.yml

（中略）
vusers.completed: .............................................................. 1
vusers.created: ................................................................ 1
vusers.created_by_name.0: ...................................................... 1
vusers.failed: ................................................................. 0
vusers.session_length:
  min: ......................................................................... 11675.1
  max: ......................................................................... 11675.1
  median: ...................................................................... 11734.2
  p95: ......................................................................... 11734.2
  p99: ......................................................................... 11734.2
Done in 14.73s.
```

## CSV 対応

パフォーマンステストでは、VU 毎に異なるアカウントでシナリオを実行するといったことは当然行いたいところです。
次のようにすることで、CSV からパラメータを読み込んで Playwright を実行することが可能です。


```diff_yaml:tests/artillery.yml
  target: https://tofuchic.github.io/kancolle # Playwright engine doesn't use this target
+ payload:
+   - path: ./env.csv
+     fields:
+       - "username"
+       - "password"
  # Enable the Playwright engine:
```

```diff_javascript:tests/playwright.js
（中略）
-async function helloKancolle(page) {
+async function helloKancolle(page, userContext) {
（中略）
- await page.getByLabel('Phone, email, or username').fill('yourTwitterAccountName');
+ await page.getByLabel('Phone, email, or username').fill(userContext.vars.username);
（中略）
- await page.getByRole('textbox', { name: 'Password Reveal password' }).fill('yourTwitterAccountPassword');
+ await page.getByRole('textbox', { name: 'Password Reveal password' }).fill(userContext.vars.password);
（中略）
```

```env.csv
yourTwitterAccountName,yourTwitterAccountPassword
```

YAML の `payload` オプション次第で、CSV から順番通りに参照するかランダムに参照するかも制御できます。

## CI/CD パイプラインへの組み込み

:::note warn
後日検証し記載します。
本記事を投稿前日の夜中に書いていまして、残念ながら力尽きました。
:::


# 課題

## `playwright codegen` で自動生成されたスクリプトをそのままは使えない

まず、Artillery の [Playwright プラグイン（2022 年 12 月現在最新の v0.2.1 ）の README](https://github.com/artilleryio/artillery-engine-playwright/tree/v0.2.1) を読むと、`playwright codegen` コマンドで画面をぽちぽちして生成できるスクリプトと微妙に記法が違うことが分かります。

専用のプラグインを導入しないと js 内の import 文も実行できないので注意が必要です。

本記事のハンズオンのように、ヘッドレスだとうまく動作しないという場合もあるようです。（幸い私の参画している PJ ではヘッドレスでも動作しているので問題にはなっていません。）

またフロントエンドのウェブアクセシビリティ対応が不十分な場合など、自動生成されるスクリプト内の CSS セレクタが非常に限定的なものとなることがあり、VU 毎に読み込む CSV パラメータの変化に追随できない場合があります。

そのため、どうしても人が手を加える必要はあります。

# Tips
## cast の仕組み

Artillery で普通に API テストをすることもあるでしょう。
その場合に注意しないといけないのが CSV から変数に落とし込むときの cast の仕様です。
Artilley では CSV から変数に値を読み込む際に、全ての変数を可能な限り cast するか、全て string として読み込むかの二択しかありません。
まだまだ未熟な OSS なのでしょうね。そのうち改善されるかもしれませんが、ワークアラウンドがあるので紹介します。

たとえば、以下のようなリクエストボディを送ることを考えます。

```送りたいリクエスト.json
{
  "data": {
    "number": 10,
    "number_as_string": "20"
  }
}
```

次のような CSV を参照するとします。

```env.csv
10,"20"
```

Artillery の YAML を以下のように設定してしまうと、どうなリクエストが送られるでしょう。

```問題_1.yaml
config:
  target: https://target.api
  phases:
    - arrivalRate: 1
      duration: 1
      name: "test phase"
  payload:
    - path: "env.csv"
      fields:
        - "number"
        - "number_as_string"
      cast: false
      order: sequence
scenarios:
  - name: "test scenario"
    flow:
      - post:
          url: "/test/url"
          json:
            data:
              number: "{{ number }}"
              number_as_string: "{{ number_as_string }}"
```

正解はこうなります。

```実際に送られるリクエスト_1.json
{
  "data": {
    "number": "10",
    "number_as_string": "20"
  }
}
```

`number` に `string` が入ってしまいますね。
では cast を有効化するとどうなるでしょう。

```問題_2.yaml
config:
  target: https://target.api
  phases:
    - arrivalRate: 1
      duration: 1
      name: "test phase"
  payload:
    - path: "env.csv"
      fields:
        - "number"
        - "number_as_string"
      cast: true
      order: sequence
scenarios:
  - name: "test scenario"
    flow:
      - post:
          url: "/test/url"
          json:
            data:
              number: "{{ number }}"
              number_as_string: "{{ number_as_string }}"
```

すると、こうなります。

```実際に送られるリクエスト_2.json
{
  "data": {
    "number": 10,
    "number_as_string": 20
  }
}
```

`number_as_string` が `number` 型になってしまいましたね。
そこで、次のように設定することで所望するリクエストを送ることができます。

```ワークアラウンド.yaml
config:
  target: https://target.api
  phases:
    - arrivalRate: 1
      duration: 1
      name: "test phase"
  payload:
    - path: "env.csv"
      fields:
        - "number"
        - "number_as_string"
      cast: false
      order: sequence
    - path: "env.csv"
      fields:
        - "cast_number"
        - "cast_number_as_string"
      cast: true
      order: sequence
scenarios:
  - name: "test scenario"
    flow:
      - post:
          url: "/test/url"
          json:
            data:
              number: "{{ cast_number }}"
              number_as_string: "{{ number_as_string }}"
```

# おわりに

私の参画している PJ では、CUJ（クリティカルユーザージャーニー）を定義して、E2E テストで CUJ が問題なく実行できることを継続的にテストしています。
当初は Cypress でテストしていたのですが、とあるタイミングで Cypress がうまく動かせなくなった[^2]ので、E2E テストツールを Playwright に変更しました。
せっかく E2E テストで CUJ を再現しているのにこれを負荷掛けに応用しない手はないと考え、調査の結果、Playwright のスクリプトを流用できる Artillery を使うことにしました。

PJ で負荷を掛けているフロントエンドは React / Typescript 製で useSWR が使われているため、ユーザが明示的に操作していなくともバックエンドにリクエストが飛びます。そのため、ユーザが実際にシステムを使っているときの負荷を再現するという意味でも、ブラウザテストを負荷掛けに流用することは非常に理に適っていると言えます。
こういったブラウザの挙動を API テストの形で実現（＝ YAML 形式で記述）しようと思うと、とてもではありませんが手が足りません。

皆さんもぜひ、Playwright x Artillery を試してみてください。

また余談ですが、やはり同じ MS 製というところで、将来的に Playwright x Azure Load Testing という組み合わせが誕生するのではないかと個人的には期待しています。
MS さん、よろしくお願いいたします。

[^2]: Azure Pipelines で MS ホスティッドエージェントを使う分には問題なく Cypress が実行できると思います。しかし、NW セキュリティ上の問題を解決するために VMSS でセルフホスティッドエージェントを作おうとしたところ、Cypress が動かず、ネット上で検索するにどうにも沼りそうだったため Cypress を辞めるに至りました。