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 の手順はありません。
$ mkdir playwright-api && cd playwright-api
$ npm init -y
$ npm install --save-dev @playwright/test@1.64.0設定ファイルではベース URL を指定し、テストで /api/Products のようなパスを使えるようにします。あわせて、すべての リクエストに既定の Accept ヘッダーを付けます。
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 を返します。このファイルでは、一覧エンドポイント、エラーボディ、検証エラー、いくつかのステータス コードを確認します。
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 を渡します。
// 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 つのファイルにまとめています。
// 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 の商品は共有されています。作成した商品は削除されるまで誰からも見えます。共有環境に対するテストは、途中で 失敗した場合も含めて、自分で後片付けをする必要があります。
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.64 | request.addCookies() / cookies() / clearCookies() | ブラウザコンテキストと同じように、リクエストコンテキストの Cookie を設定・削除する |
| 1.64 | --shuffle [seed] | テストをランダムな順序で実行し、別のテストの後でしか通らないテストを見つける |
| 1.63 | request.get<T>() | 型付きの response.json()(上で紹介) |
| 1.63 | lock | 同じデータを変更するテストを同時に実行しない |
| 1.62 | apiResponse.timing() | 1 回の呼び出しの DNS、接続、リクエスト、レスポンスの時間 |
| 1.62 | signal | AbortSignal でリクエストをキャンセルする |
| 1.62 | retryStrategy: "isolated" | リトライを最後に 1 つずつ実行する |
このファイルでは、リクエストに関わる機能を試します。私たちの /api/echo エンドポイントは受け取ったリクエストをそのまま返すので、Playwright が何を送ったかが正確にわかります。
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 は逆の問題を見つけます。ときどきスイートをランダムな順序で実行すれば、別のテストの後でしか通らないテストが見つかります。
$ npx playwright test --shuffleスイートを実行する
$ npx playwright testRunning 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 で結果を確かめられます。テストごとにフォームを操作して準備するより ずっと速く済みます。
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 ボタンで各 リクエストを試せます。