はじめに
Webアプリを運用していると、障害の原因がアプリ本体ではなく「入口(NginxやLB、リバースプロキシ)」にあるケースが意外と多いです。たとえば、こんな変更はよく起こります。
/apiのルーティングを変えた(locationの優先順位、末尾スラッシュの扱い)- SPA配信の
try_filesを触った - gzipやキャッシュ周りのヘッダを整理した
- 301/302のリダイレクトを追加した(http→https、wwwあり/なし)
- 認証やIP制限の位置を調整した
こういう変更は「設定ファイルをちょっと直しただけ」でも、最悪の場合はサイト全体が落ちる、あるいは特定のページだけ壊れて気づきにくいという状態になります。しかも、入口が壊れるとアプリ側のメトリクスやログでは追いづらく、「障害なのにアプリは元気」みたいな現象が起きます。
だからこそ、入口の挙動は E2Eテストで“契約(Contract)”として固定化しておく価値があります。E2Eというと「画面操作」のイメージが強いですが、入口に対しては、
- ルーティングが想定どおりか
- ヘッダが付いているか
- キャッシュ・圧縮・CORS・リダイレクトが正しいか
- エラー時の挙動が正しいか(404/502/タイムアウト)
を、ブラウザ(Playwright) と 負荷・レイテンシ(k6) の両面から押さえるのが実戦的です。
この記事では、Dockerで立ち上げた Nginx + API(Go/Nodeなど)の入口に対して、Playwrightとk6でテストを作り、CIに入れたくなる形まで持っていきます。
座学
1) 入口E2Eで何を守るべきか(壊れやすい“契約”の一覧)
入口のテストは、画面の見た目より「通信の契約」を守るのが本質です。代表的には次のカテゴリがあります。
(A) ルーティング
/は静的配信(indexが返る)/assets/*は静的が返る(拡張子別に正しいContent-Type)/api/*はバックエンドへプロキシされる(アプリが返すJSONが返る)
(B) リダイレクト
/old→/newが 301/302 で正しい- 末尾スラッシュの正規化(
/apiと/api/の扱い) - http→https(本番想定なら)
(C) ヘッダ
Cache-Controlが適切(静的は長め、HTMLは短め等)Content-Security-Policy/X-Content-Type-Optionsなどのセキュリティヘッダ(導入している場合)- プロキシ系:
X-Forwarded-For/X-Forwarded-Proto(アプリが必要なら)
(D) エラー時の挙動
- 404ページが期待どおり(SPAはindexへフォールバックなど)
- upstreamが落ちた時に 502 が返る(タイムアウトが無限に待たない)
- 過剰なリトライで雪だるまにならない
入口はこの契約が壊れると破壊力が大きいので、ここをテストで“封じ込め”ます。
2) Playwright と k6 の棲み分け
Playwright はブラウザとしての現実に強いです。
- DOMが正しくレンダリングされるか
- SPAのルーティングが動くか(ページ更新含む)
- フロントから
/apiへアクセスして成り立つか
一方で、入口のテストは画面操作を最小化しても十分です。HTTPレベルの検証(status/headers/body) を多めにすると、テストが速く・壊れにくくなります。
k6 は負荷とレイテンシの目線に強いです。
- p95/p99 が悪化していないか
- 入口がボトルネックになっていないか
- 失敗率が上がっていないか
- 同時接続で502が増えないか
つまり、
- 仕様が壊れたか? → Playwright
- 性能が壊れたか? → k6
という役割で、入口を二重に守るのが強いです。
3) “入口テスト”の設計原則(テストを壊れにくくする)
入口テストでありがちな失敗は「UIの見た目に寄せすぎて、ちょっとした文言変更で落ちる」ことです。入口テストは次の方針が堅いです。
- テスト対象は“HTTP契約”を優先(status/header/body)
- DOM検証は最小に(ページが表示できる程度)
- テストケース名は契約そのものにする
例:「/api/* は upstream に到達できる」「/about を更新しても 200(SPA)」
ハンズオン
ここでは「Nginxが入口、/ が静的、/api/health がAPI」という前提で、Playwrightとk6のテストを作ります。前の記事のDocker Compose構成(Nginx + Go/Node)を想定し、入口は http://localhost:8088 にあるものとします。
0) テスト用ディレクトリを追加
プロジェクト直下にテスト用を追加します。
mkdir -p e2e/playwright
mkdir -p e2e/k6
1) Playwright:入口の契約テスト(HTTP中心)
(1) Playwright導入
Nodeがある前提で、E2E専用のpackageを切ります(アプリと依存を混ぜないため)。
cd e2e/playwright
npm init -y
npm i -D @playwright/test
npx playwright install
(2) Playwright設定(baseURLを固定)
e2e/playwright/playwright.config.js
import { defineConfig } from "@playwright/test";export default defineConfig({
use: {
baseURL: process.env.BASE_URL || "http://localhost:8088",
},
testDir: "./tests",
retries: 0,
});
(3) テストを書く(入口の“壊れやすい契約”を並べる)
e2e/playwright/tests/entry.spec.js
import { test, expect } from "@playwright/test";test("GET / は 200 で HTML を返す(静的配信)", async ({ request }) => {
const res = await request.get("/");
expect(res.status()).toBe(200); const ct = res.headers()["content-type"] || "";
expect(ct).toContain("text/html"); const body = await res.text();
// 文言依存にしすぎない:最低限の要素だけ
expect(body.toLowerCase()).toContain("html");
});test("GET /api/health は 200 で JSON を返す(プロキシ)", async ({ request }) => {
const res = await request.get("/api/health");
expect(res.status()).toBe(200); const ct = res.headers()["content-type"] || "";
expect(ct).toContain("application/json"); const json = await res.json();
expect(json.ok).toBe(true);
expect(json.service).toBeTruthy();
});test("SPA想定:存在しないパスでも /index.html にフォールバックして 200", async ({ request }) => {
const res = await request.get("/some/unknown/path");
expect(res.status()).toBe(200); const ct = res.headers()["content-type"] || "";
expect(ct).toContain("text/html");
});
ここではブラウザ操作ではなく request API を使っています。入口のテストはこの方が速くて安定します。
(4) 実行
入口(docker compose)が起動している状態で、
npx playwright test
2) Playwright:最低限の“ブラウザ目線”を1本だけ
入口の変更で「ページ更新したら壊れる」問題はよくあるので、1本だけページ操作を入れておくと守りが固くなります。
e2e/playwright/tests/browser.spec.js
import { test, expect } from "@playwright/test";test("ブラウザで / を開ける(最低限のレンダリング)", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/.*/); // タイトルが空でも落とさない
});
UIが整っていない段階でも落ちにくい、最低限のチェックです。
3) k6:入口の性能・安定性テスト(p95/失敗率)
(1) k6の実行方法
k6はローカルに入れても良いですが、入口の再現性に寄せるならDockerで回すのが相性が良いです。
以降は Dockerでk6 を使う前提にします。
(2) スクリプトを書く
e2e/k6/entry.js
import http from "k6/http";
import { check, sleep } from "k6";export const options = {
vus: 20,
duration: "20s",
thresholds: {
http_req_failed: ["rate<0.01"], // 失敗率1%未満
http_req_duration: ["p(95)<400"], // p95 400ms 未満(目安)
},
};const BASE_URL = __ENV.BASE_URL || "http://host.docker.internal:8088";export default function () {
// 静的
const res1 = http.get(`${BASE_URL}/`);
check(res1, {
"GET / is 200": (r) => r.status === 200,
"GET / is html": (r) => (r.headers["Content-Type"] || "").includes("text/html"),
}); // API
const res2 = http.get(`${BASE_URL}/api/health`);
check(res2, {
"GET /api/health is 200": (r) => r.status === 200,
"GET /api/health is json": (r) => (r.headers["Content-Type"] || "").includes("application/json"),
}); sleep(0.2);
}
ポイント
- 入口の負荷テストは「APIだけ」ではなく、
/と/apiを混ぜた方が現実に近いです - しきい値(thresholds)は最初はゆるめでOK。継続運用で絞るのが現実的です
(3) 実行
Windows/MacのDocker環境でホストへ到達するために host.docker.internal を使っています(Linuxだと別指定が必要な場合があります)。
docker run --rm -i grafana/k6 run - < e2e/k6/entry.js
baseURLを変えたいとき:
docker run --rm -i -e BASE_URL=http://host.docker.internal:8088 grafana/k6 run - < e2e/k6/entry.js
4) “入口テスト”をCIへ持っていく最小形
CIの話は環境差が出やすいので、ここでは考え方だけ最小でまとめます。
- ComposeでNginx+APIを起動
- Playwrightで契約が壊れてないか
- k6で失敗率とp95が劣化してないか
- 終了後にComposeを落とす
入口テストは「落ちたらすぐ戻す」価値が高いので、CIに入れると効きます。特に、Nginx設定変更はレビューしづらいぶん、機械で守る効果が大きいです。
まとめ
入口(Nginx)の変更は、小さく見えて破壊力が大きい領域です。だからこそ、E2Eで“契約”として固定化しておくと安心感が段違いになります。
- Playwrightは 仕様(ルーティング/ヘッダ/フォールバック) を守る
- k6は 性能(p95/失敗率) を守る
- UIの見た目より HTTP契約中心 にするとテストが壊れにくい
/と/apiを混ぜて、入口の現実に近い負荷をかけると効く
この2本立てを入門の段階で持っておくと、今後どんな構成(TLS、CDN、LB、認証)に進んでも「入口が壊れてない」を確認できる土台になります。
コメントを残す