E2Eが書けたので、負荷テストもこれでいけるのでは
PlaywrightでE2Eテストが一通り書けるようになると、必ず一度は思うことがあります。「このシナリオをそのまま何十人分も同時に走らせれば、負荷テストになるのでは」という発想です。私も1年目の頃、似たようなことを考えて試したことがあります。
結論から言うと、これは半分正解で半分不正解でした。Playwrightは本物のブラウザを操作して人間の操作を再現するためのツールであって、大量の同時アクセスを軽量にさばくために作られたツールではありません。この前提を最初にはっきりさせておきます。
ブラウザを100個立ち上げようとして詰まる
試しに、Playwrightのテストコードをそのままループで回して、仮想ユーザーを増やしてみたとします。
for (let i = 0; i < 100; i++) {
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com/');
await page.click('text=ログイン');
})();
}
これをローカルで動かすと、そこそこの台数で早々にメモリとCPUが悲鳴を上げます。ブラウザは1つ起動するだけでもそれなりに重いプロセスです。それを100個、1000個と並べるのは、負荷テストというより自分のマシンへの負荷テストになってしまいます。
負荷テストで本来知りたいのは「サーバーが何人までのアクセスに耐えられるか」です。そのためにクライアント側までブラウザで再現する必要は、実はあまりありません。ここで初めて、Playwright単体では役不足だということに気付きます。
書いたE2Eをそのまま活かしたいならArtillery
とはいえ、せっかく書いたPlaywrightのシナリオを丸ごと捨てるのはもったいない話です。ここで使えるのが、負荷テストツールArtilleryが公式に提供しているPlaywrightエンジンです。
これは実ブラウザを使った負荷テストのために、Artilleryがブラウザの起動・仮想ユーザーの並列実行・メトリクス収集を肩代わりしてくれる仕組みです。書く側は、いつも通りPlaywrightのpageを操作するコードを用意するだけで済みます。
// flows.js
async function checkout(page) {
await page.goto('https://example.com/');
await page.click('text=ログイン');
await page.fill('#email', 'user@example.com');
await page.click('button[type=submit]');
}
module.exports = { checkout };
# load-test.yml
config:
target: 'https://example.com'
engines:
playwright: {}
processor: './flows.js'
phases:
- duration: 60
arrivalRate: 5
scenarios:
- engine: playwright
testFunction: 'checkout'
見た目はほぼ普通のPlaywrightコードです。arrivalRateで1秒あたりの新規仮想ユーザー数を指定すると、Artilleryがその数だけブラウザを起動してcheckout関数を並列に流してくれます。
導入自体は難しくありません。Artillery本体をインストールし、Playwrightのブラウザバイナリを用意すれば準備完了です。
npm install -g artillery
npm install playwright
npx playwright install chromium
Playwrightエンジン自体はArtilleryに標準で組み込まれているので、エンジン用の追加パッケージを別途入れる必要はありません。ここは瞬殺ですね。あとはnpx artillery run load-test.ymlを叩くだけです。
実行が終わると、ターミナルに集計結果が出力されます。
Summary report @ 12:03:41(+0900)
http.response_time:
min: 412
max: 3021
p50: 588
p95: 1420
p99: 2210
vusers.created: 300
vusers.completed: 296
vusers.failed: 4
p95やp99は、仮想ユーザーの95%・99%がどれくらいの時間内に処理を終えたかを表す値です。vusers.failedが0でなければ、その仮想ユーザーのシナリオの中でどこかのステップが失敗しています。ここでArtilleryのtrace記録機能が効いてきて、失敗した仮想ユーザーだけtraceを残してくれるので、原因を1件ずつ追いかけられます。数字がずらっと並んで出てきたら、まずはここまでたどり着けたということなので成功です。
実ブラウザで負荷をかけるので、JavaScriptのレンダリングやフロントの重い処理まで含めて実際のユーザー体験に近い負荷を再現できるのが強みです。失敗した仮想ユーザーのtraceを自動で記録してくれる機能は、以前紹介したtrace CLIの話と同じ発想で「何が起きたか」を後から追えるようになっています。
ただし正直なところ、ブラウザを起動するコストの高さ自体はArtilleryを使っても消えません。1台のマシンで現実的に回せる仮想ユーザー数は、HTTPだけを叩く負荷テストに比べるとかなり少なくなります。数百〜数千規模の同時接続を再現したい負荷テストには、そもそも向かないツールだと考えた方がよさそうです(このあたりの上限は手元のマシンスペックにも左右されるので、まず小さいarrivalRateから試して様子を見るのが安全です)。
CIで毎回軽く回すならk6
一方で、負荷テストをCIに常設して毎回自動で回したいという要件だと、話が変わってきます。ここで出番になるのがk6です。
k6はHTTPリクエストのレベルで負荷をかけるツールです。ブラウザを起動しないぶん圧倒的に軽く、同じマシンでも桁違いに多い仮想ユーザー数を再現できます。以前GitHub ActionsでPlaywrightのCIを組んだ記事を書きましたが、k6のスクリプトも同じワークフローの中に1ステップ足すだけで組み込めます。
ただ、k6はブラウザを使わない分、Playwrightで書いた「クリックして」「入力して」というシナリオをそのまま持ち込むことはできません。ここで役に立つのがHARファイルからの変換です。
ブラウザの開発者ツールでネットワークタブを開き、一連の操作を記録してHARファイルとして保存します。実はPlaywright側でもpage.context().on('request', ...)やトレースからHTTPリクエストの流れを追うことができるので、Playwrightで一度動作確認したシナリオを、ブラウザの記録機能でHARに落とし込むという流れが現実的です。そのHARファイルをk6用のスクリプトに変換します。
k6 har-to-k6 session.har -o load-test.js
k6 run load-test.js
変換後のスクリプトは、画像やCSSといった静的アセットへのリクエストが自動で除外され、実際に負荷をかけたいAPIやページ遷移のリクエストだけが残る形になっています。生成されたコードにvus(仮想ユーザー数)やdurationを指定するだけで、CIの中で毎回数十秒程度の軽い負荷テストとして走らせられます。
正直に書いておくと、HARからk6スクリプトへの変換ツールは、k6本体に組み込まれたhar-to-k6コマンドと、npmパッケージとして単体配布されているhar-to-k6、さらにGrafana k6 Studioという録画ツールが並行して存在していて、どれが今の推奨なのか出典によって書きぶりが揺れていました。この記事のコマンド例も執筆時点で確認できた形を書いていますが、手元で動かす際は公式ドキュメントで最新のコマンド名を確認していただくのが確実です。
k6自体はnpmパッケージではなく単体のバイナリとして配布されているので、Homebrewやパッケージマネージャーでインストールします。
# macOS
brew install k6
# Windows (winget)
winget install k6 --source winget
実行すると、Playwrightのテストレポートとはだいぶ違う見た目の結果が出てきます。
checks.........................: 100.00% 300 out of 300
http_req_duration...............: avg=210ms min=98ms med=180ms max=890ms p(95)=410ms
http_reqs........................: 1200 40/s
vus..............................: 20 min=20 max=20
iterations........................: 300
checksはスクリプト内で書いたレスポンスの検証(ステータスコードが200かどうか等)の合格率です。ここが100%を割り込んでいたら、負荷をかける前にまずアプリ側のエラーを疑うべきサインになります。http_req_durationのp(95)がじわじわ伸びていくようなら、サーバーが仮想ユーザー数の増加についてこられていない証拠です。Artilleryの結果が「実ブラウザで見た体感速度」だったのに対し、こちらは「サーバーが素で出せているレスポンスタイム」に近い数字になります。同じ「負荷テスト」という言葉でも、見ている場所がまったく違うことがこの2つの出力を並べるとよく分かります。
2つを混同しないための判断軸
ここまでの話を整理すると、判断軸はシンプルです。
- 書いたE2Eシナリオの操作感(クリック・入力・待機)をそのまま流用して、実ブラウザでのユーザー体験込みの負荷を見たい → Artillery + Playwrightエンジン
- CIに毎回組み込んで、サーバー側のAPIやレスポンスタイムを軽く継続的に見張りたい → k6(HAR変換で初期シナリオを作る)
両方を同じ用途だと思って選んでしまうと、必ずどちらかで無理が出ます。ブラウザ込みの重い負荷をk6でやろうとしても再現できませんし、CIで毎回数百仮想ユーザー分のブラウザをArtilleryで起動しようとすると、CIのマシンリソースが先に音を上げます。私も最初はこの区別がついておらず、CIに組み込むつもりでArtilleryのPlaywrightエンジンを使おうとして、実行時間が想定の何倍にも膨れ上がった経験があります。
実務でよくあるのは、この2つを併用するパターンです。リリース前や月次のタイミングでArtilleryによる実ブラウザ負荷テストを手動または低頻度で回し、日々のCIではk6で軽くAPIの応答時間だけを見張っておく。役割の違うツールを2本立てで持っておく方が、結果的に運用は楽になります。
まとめ
「E2Eが書けたので負荷テストも」という発想自体は、決して的外れではありません。ただしPlaywright単体は負荷テストのために設計されたツールではなく、そのまま並列実行しようとすると自分のマシンが先に限界を迎えます。書いたシナリオを活かしたいならArtilleryのPlaywrightエンジン、CIで軽く継続的に見たいならk6のHAR変換という、それぞれ別の道具に頼るのが正解だと感じました。
どちらから試すか迷ったら、まずは手元のPlaywrightシナリオをArtilleryのconfigに1本だけ流し込んでみるのがおすすめです。実際に動く様子を見ると、負荷テストというものの感触がつかみやすくなります。CLIの細かいオプションで迷った際はCLIオプションの記事も参考にしてみてください。

コメント