Playwright で API テスト:動くスイートと、1.62〜1.64 で加わったもの

シェア

Playwright はブラウザのテストツールとして知られていますが、完全な HTTP クライアントも内蔵しています。それが request フィクスチャで、これを使えばブラウザを一切起動せずに API テストを書けます。テストは同じランナーで動き、UI テストと同じレポート、リトライ、並列ワーカーが使えます。

この記事では、私たちの無料テスト API に対して、小さな API テストスイートを作ります。どの例も 2026 年 10 月 7 日にリリースされた Playwright 1.64.0 で実行し、12 個のテストがすべて通りました。最後のパートでは、直近 3 つのリリースが API テスト向けに追加したものを紹介します。型付きのレスポンス、データを共有するテスト用のロック、リクエストコンテキストの Cookie、レスポンスのタイミング、そしてリクエストのキャンセルです。

準備

Node.js 20 以降が必要です。API テストにブラウザは要らないので、npx playwright install の手順はありません。

Terminal
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0

設定ファイルではベース URL を指定し、テストで /api/Products のようなパスを使えるようにします。あわせて、すべての リクエストに既定の Accept ヘッダーを付けます。

playwright.config.ts
import { defineConfig } from "@playwright/test";

// API tests only: no browsers are launched, so `npx playwright install` isn't needed.
export default defineConfig({
  testDir: "./tests",
  reporter: "list",
  retries: 1,
  // Playwright 1.62: run retries at the end, one at a time, instead of straight away in parallel.
  retryStrategy: "isolated",
  use: {
    // Point the tests at another server with BASE_URL, e.g. a copy of the API on your machine.
    baseURL: process.env.BASE_URL ?? "https://api.testingapis.com",
    extraHTTPHeaders: { Accept: "application/json" },
  },
});

BASE_URL を使えば、テストを書き換えずに、API のローカルコピーやステージング環境など別のサーバーに向けられます。

最初の API テスト

テストで request フィクスチャを受け取ると、get、post、put、patch、delete、fetch を持つ APIRequestContext が手に入ります。どれもステータス、ヘッダー、ボディを持つ APIResponse を返します。このファイルでは、一覧エンドポイント、エラーボディ、検証エラー、いくつかのステータス コードを確認します。

tests/read.spec.ts
import { expect, test } from "@playwright/test";
import type { ApiError, Product } from "./types";

test("lists products filtered by category, with paging headers", async ({ request }) => {
  const response = await request.get<Product[]>("/api/Products", {
    params: { category: "electronics", limit: 2 },
  });

  expect(response.status()).toBe(200);
  expect(response.headers()["content-type"]).toContain("application/json");
  expect(Number(response.headers()["x-total-count"])).toBeGreaterThan(2);

  const products = await response.json();
  expect(products).toHaveLength(2);
  for (const product of products) {
    expect(product.category).toBe("electronics");
    expect(typeof product.price).toBe("number");
  }
});

test("returns 404 with an error body for a product that doesn't exist", async ({ request }) => {
  const response = await request.get<ApiError>("/api/Products/99999");

  expect(response.status()).toBe(404);
  expect(await response.json()).toEqual({
    status: 404,
    error: "Not Found",
    message: "Product with ID 99999 does not exist.",
  });
});

test("rejects a product without a title", async ({ request }) => {
  const response = await request.post<ApiError>("/api/Products", { data: { price: 5 } });

  expect(response.status()).toBe(400);
  expect((await response.json()).message).toBe("Missing required field: title");
});

for (const code of [400, 401, 429, 500, 503]) {
  test(`handles a ${code} response`, async ({ request }) => {
    const response = await request.get(`/api/status/${code}`);

    expect(response.status()).toBe(code);
    expect(response.ok()).toBe(false);
  });
}
  • params がクエリ文字列を組み立てるので、?category=electronics&limit=2 を手でエンコードする必要はありません。
  • response.headers() はヘッダー名を小文字で返します。私たちの API は条件に合う商品の総数を X-Total-Count で返すので、テストではボディに加えてページングも確認しています。
  • post の data は JSON として送られ、Content-Type: application/json も自動で付きます。
  • for ループはステータスコードごとにテストを 1 つ作ります。/api/status/{code} は指定したステータスを返すので、自分のクライアントコードが 429 や 503 にどう反応するかを確かめるのに便利です。

404 や 500 が返っても request.get は例外を投げません。レスポンスを受け取ってアサーションするだけで、エラーそのものを テストしたいときにはこれが望ましい動きです。エラーステータスならそのままテストを失敗させたい場合は、failOnStatusCode を渡します。

typescript
// Throw on any 4xx or 5xx instead of returning the response.
const response = await request.get("/api/Products/1", { failOnStatusCode: true });

レスポンスに型を付ける

Playwright 1.63 から、リクエストメソッドは型引数を受け取り、response.json() がその型を返すようになりました。 上の request.get<Product[]> がそれです。型は 1 つのファイルにまとめています。

tests/types.ts
// The shapes the tests read. Only the fields they check are listed.

export interface Product {
  id: number;
  title: string;
  price: number;
  category: string;
  inStock: boolean;
}

/** What POST and PUT on /api/Products return. */
export interface ProductEnvelope {
  message: string;
  data: Product;
}

/** The API's error body, for 4xx and 5xx responses. */
export interface ApiError {
  status: number;
  error: string;
  message: string;
}

これは TypeScript へのヒントで、検証ではありません。数値と宣言したところに API が文字列を返しても、テストはコンパイルされて 実行されます。上のテストが typeof product.price で行っているように、大事な値にはアサーションを残してください。 レスポンスボディ全体をスキーマで検証したい場合は、レスポンス検証のレッスン(英語)を参照してください。

後片付けまでする作成・取得・更新・削除のテスト

私たちの API の商品は共有されています。作成した商品は削除されるまで誰からも見えます。共有環境に対するテストは、途中で 失敗した場合も含めて、自分で後片付けをする必要があります。

tests/crud.spec.ts
import { expect, test } from "@playwright/test";
import type { Product, ProductEnvelope } from "./types";

// Products are shared by everyone who uses the API. Playwright 1.63's lock keeps any other test that writes
// products from running at the same time as this file, in any worker.
test.describe.configure({ lock: "products" });

test("creates, reads, updates and deletes a product", async ({ request }) => {
  const created = await request.post<ProductEnvelope>("/api/Products", {
    data: { title: "Playwright test product", price: 12.5, category: "electronics" },
  });
  expect(created.status()).toBe(201);
  const { data: product } = await created.json();

  try {
    await test.step("read it back", async () => {
      const read = await request.get<Product>(`/api/Products/${product.id}`);
      expect(read.status()).toBe(200);
      expect(await read.json()).toMatchObject({ id: product.id, title: "Playwright test product", price: 12.5 });
    });

    await test.step("change the price", async () => {
      const updated = await request.put<ProductEnvelope>(`/api/Products/${product.id}`, {
        data: { title: "Playwright test product", price: 9.99, category: "electronics" },
      });
      expect(updated.status()).toBe(200);
      expect((await updated.json()).data.price).toBe(9.99);
    });
  } finally {
    // Clean up even when a step fails, so a broken run doesn't leave products behind.
    const deleted = await request.delete(`/api/Products/${product.id}`);
    expect(deleted.status()).toBe(200);
  }

  const gone = await request.get(`/api/Products/${product.id}`);
  expect(gone.status()).toBe(404);
});
  • finally ブロックは、取得や更新が失敗しても商品を削除するので、壊れた実行がテストデータを残すことはありません。
  • test.stepは各段階にレポートとトレースビューアー上の名前を付けます。失敗したとき、23 行目を指すのではなく "change the price" と表示されます。
  • 最後のリクエストで、商品が本当に消えたかを確認します。200 を返すのにレコードが残る削除は本物のバグで、後から取得しない と見つかりません。
  • test.describe.configure({ lock: "products" }) は 1.63 の新機能です。同じロック名を持つテストは、ファイルや ワーカーをまたいでも同時には実行されません。商品を書き込むテストすべてに同じロックを付ければ、互いのデータを踏み荒らす ことはなく、スイートの残りは並列のまま動きます。

CRUD テストのレッスンでは、各ステップに必要なチェックと、2 回削除することも役立つ テストになる理由を説明しています。

1.62〜1.64 で API テストに加わったもの

バージョン追加されたもの用途
1.64request.addCookies() / cookies() / clearCookies()ブラウザコンテキストと同じように、リクエストコンテキストの Cookie を設定・削除する
1.64--shuffle [seed]テストをランダムな順序で実行し、別のテストの後でしか通らないテストを見つける
1.63request.get<T>()型付きの response.json()(上で紹介)
1.63lock同じデータを変更するテストを同時に実行しない
1.62apiResponse.timing()1 回の呼び出しの DNS、接続、リクエスト、レスポンスの時間
1.62signalAbortSignal でリクエストをキャンセルする
1.62retryStrategy: "isolated"リトライを最後に 1 つずつ実行する

このファイルでは、リクエストに関わる機能を試します。私たちの /api/echo エンドポイントは受け取ったリクエストをそのまま返すので、Playwright が何を送ったかが正確にわかります。

tests/new-in-1-62-to-1-64.spec.ts
import { expect, test } from "@playwright/test";

// /api/echo returns what it received, which makes it easy to see what the request context sent.
interface Echo {
  method: string;
  cookies: Record<string, string>;
}

test("1.64: cookies added to the request context are sent with every request", async ({ request, baseURL }) => {
  await request.addCookies([{ name: "session-id", value: "42", url: baseURL! }]);

  const withCookie = await request.get<Echo>("/api/echo");
  expect((await withCookie.json()).cookies).toEqual({ "session-id": "42" });

  await request.clearCookies({ name: "session-id" });
  const withoutCookie = await request.get<Echo>("/api/echo");
  expect((await withoutCookie.json()).cookies).toEqual({});
});

test("1.62: apiResponse.timing() shows where the time went", async ({ request }) => {
  const response = await request.get("/api/health");
  const timing = response.timing();

  // Each value is milliseconds after startTime, or -1 when it doesn't apply (no DNS lookup on a reused
  // connection, for example).
  expect(timing.startTime).toBeGreaterThan(0);
  expect(timing.responseEnd).toBeGreaterThanOrEqual(timing.responseStart);
  expect(timing.responseEnd).toBeLessThan(5_000);
});

test("1.62: an AbortSignal cancels a request that takes too long", async ({ request }) => {
  // ?delay= makes the API wait before answering. The signal gives up after 500 ms.
  const pending = request.get("/api/echo", { params: { delay: 3000 }, signal: AbortSignal.timeout(500) });

  await expect(pending).rejects.toThrow();
});
  • Cookie。リクエストコンテキストはこれまでも、レスポンスが設定した Cookie を保持し、storageState から Cookie を持って始めることもできましたが、直接追加したり削除したりはできませんでした。これからは テストの途中でできます。たとえば、セッション Cookie を消して次のリクエストが拒否されるかを確かめられます。
  • タイミング。timing() はブラウザの Resource Timing API と同じフィールドを、startTime からのミリ秒で返します。起きなかったフェーズは -1 です。再利用された接続では DNS 解決がないので、すべての フィールドを足すのではなく responseEnd を見てください。テストの 5 秒という上限はわざとゆるくしています。 インターネット上のサーバーに厳しい時間のアサーションをかけると、テストが不安定になるからです。
  • キャンセル。1.62 のリリースノートでは signal はアクションとアサーション向けと書かれていますが、 リクエストメソッドでも動きます。1.64 の型定義と上のテストで確かめました。?delay=3000 で私たちの API は 3 秒待ち、AbortSignal.timeout(500) は 0.5 秒であきらめます。リクエスト自身の timeout オプションも似た働きをしますが、早めに終わるテストのように、止めるタイミングを別の何かが決める場合には signal が便利です。

冒頭の設定ではすでに retryStrategy: "isolated" を使っています。既定では、失敗したテストはワーカーが空き 次第、スイートの残りがまだ動いている間にリトライされます。isolated のリトライは最後まで待ち、1 つずつ実行されます。並列実行 では失敗するのに isolated のリトライでは通るテストは、同じレコードを変更する 2 つのテストのように、別のテストに邪魔されて いる可能性が高く、lock で解決できます。--shuffle は逆の問題を見つけます。ときどきスイートをランダムな順序で実行すれば、別のテストの後でしか通らないテストが見つかります。

Terminal
$ npx playwright test --shuffle

スイートを実行する

Terminal
$ npx playwright test
出力(Playwright 1.64.0、Node 24、2026 年 10 月 14 日)
Running 12 tests using 3 workers

  ✓   1 tests\read.spec.ts:4:5 › lists products filtered by category, with paging headers (285ms)
  ✓   2 tests\new-in-1-62-to-1-64.spec.ts:9:5 › 1.64: cookies added to the request context are sent with every request (263ms)
  ✓   3 tests\crud.spec.ts:8:5 › creates, reads, updates and deletes a product (334ms)
  ✓   4 tests\read.spec.ts:21:5 › returns 404 with an error body for a product that doesn't exist (90ms)
  ✓   5 tests\new-in-1-62-to-1-64.spec.ts:20:5 › 1.62: apiResponse.timing() shows where the time went (104ms)
  ✓   6 tests\read.spec.ts:32:5 › rejects a product without a title (118ms)
  ✓   8 tests\read.spec.ts:40:7 › handles a 400 response (109ms)
  ✓   9 tests\read.spec.ts:40:7 › handles a 401 response (107ms)
  ✓  10 tests\read.spec.ts:40:7 › handles a 429 response (120ms)
  ✓   7 tests\new-in-1-62-to-1-64.spec.ts:31:5 › 1.62: an AbortSignal cancels a request that takes too long (512ms)
  ✓  11 tests\read.spec.ts:40:7 › handles a 500 response (107ms)
  ✓  12 tests\read.spec.ts:40:7 › handles a 503 response (97ms)

  12 passed (2.1s)

スイート全体は約 2 秒で終わります。playwright-api-examples.zip をダウンロードし、npm install と npx playwright test を実行すれば、同じ API に対して動きます。使っている エンドポイントはすべて無料で、アカウントもキーも要りません。

UI テストの中での API 呼び出し

同じクライアントは、ブラウザテストでも page.request として使えます。ページと Cookie を共有するので、API で テストデータを作ったりログインしたりしてから UI で結果を確かめられます。テストごとにフォームを操作して準備するより ずっと速く済みます。

typescript
test("shows a product created through the API", async ({ page }) => {
  // page.request shares cookies with the page, so a session set up here carries over to the browser.
  const created = await page.request.post("https://api.testingapis.com/api/Products", {
    data: { title: "Seeded", price: 1 },
  });
  // ...then open the page that lists products and check it appears.
});

これらのテストが呼ぶエンドポイントは、商品 API のドキュメントとステータスコード API のドキュメントに載っています。テストを書く前に、Send ボタンで各 リクエストを試せます。