入口は壊れると全滅する:Nginxの挙動をE2Eで固定化する(Playwright × k6 実践入門)

はじめに

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、認証)に進んでも「入口が壊れてない」を確認できる土台になります。


投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です