用 Playwright 做 API 测试:一个能跑的套件,以及 1.62 到 1.64 新增了什么
大多数人知道 Playwright 是浏览器测试工具。其实它还内置了一个完整的 HTTP 客户端,也就是 request fixture,用它可以写 API 测试,完全不用启动浏览器。这些测试跑在同一个 runner 里,和 UI 测试共用报告、重试和并行 worker。
这篇文章用它针对我们的免费测试 API 搭了一个小型 API 测试套件。每个示例都用 2026 年 10 月 7 日发布的 Playwright 1.64.0 跑过,12 个测试全部通过。最后一部分介绍最近三个版本为 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 fixture,就会得到一个 APIRequestContext,它有 get、post、put、patch、delete 和 fetch。每个方法都返回一个包含状态码、响应头和响应体的 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循环为每个状态码生成一个测试。/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[]> 就是这样用的。类型都放在一个文件里:
// 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 那样,保留对关键值的断言。如果要按 schema 校验整个响应体,可以看我们的响应校验课程(英文)。
会自己收尾的增删改查测试
我们 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会在报告和 trace viewer 里给每个阶段命名,所以失败时显示的是 "change the price",而不是第 23 行。- 最后一个请求检查商品是否真的没了。删除返回 200 却留下了记录,这是真正的 bug,只有之后再读一次才能发现。
test.describe.configure({ lock: "products" })是 1.63 的新功能。使用同一个锁名的测试,无论跨文件还是跨 worker,都不会同时运行。给所有写商品的测试加上同一个锁,它们就不会互相踩数据,而套件的其余部分仍然并行运行。
我们的 CRUD 测试课程讲了每一步需要哪些检查,以及为什么删除两次也是有用的测试。
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() | 一次调用的 DNS、连接、请求和响应耗时 |
| 1.62 | signal | 用 AbortSignal 取消请求 |
| 1.62 | retryStrategy: "isolated" | 把重试放到最后,一个一个地运行 |
这个文件测试了其中与请求相关的几项。我们的 /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用于 action 和断言。其实它在请求方法上也能用,我们在 1.64 的类型定义和上面的测试里都确认过:?delay=3000让我们的 API 等 3 秒,AbortSignal.timeout(500)半秒后就放弃。请求自带的timeout选项作用类似;当由别的东西决定何时停止,比如测试提前结束时,signal 就更有用。
开头的配置已经用了 retryStrategy: "isolated"。默认情况下,失败的测试会在有空闲 worker 时立即重试,此时套件的其余部分还在运行。isolated 重试会等到最后,一个一个地运行。如果某个测试在并行运行中失败、在隔离重试中却通过, 很可能是别的测试干扰了它,比如两个测试在改同一条记录,这时用 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)整个套件大约两秒跑完。下载 playwright-api-examples.zip,运行 npm install 和 npx playwright test,它就会针对同一个 API 运行。它用到的所有端点都是免费的,不需要账号或密钥。
在 UI 测试中调用 API
同一个客户端在浏览器测试里叫 page.request。它和页面共享 Cookie,所以你可以先通过 API 创建测试数据或登录,再在界面上检查结果,这比为每个测试点一遍表单来准备数据快得多:
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 按钮,可以在写测试之前先试一下每个请求。