用 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 这一步:

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 fixture,就会得到一个 APIRequestContext,它有 get、post、put、patch、delete 和 fetch。每个方法都返回一个包含状态码、响应头和响应体的 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 循环为每个状态码生成一个测试。/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[]> 就是这样用的。类型都放在一个文件里:

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 那样,保留对关键值的断言。如果要按 schema 校验整个响应体,可以看我们的响应校验课程(英文)。

会自己收尾的增删改查测试

我们 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会在报告和 trace viewer 里给每个阶段命名,所以失败时显示的是 "change the price",而不是第 23 行。
  • 最后一个请求检查商品是否真的没了。删除返回 200 却留下了记录,这是真正的 bug,只有之后再读一次才能发现。
  • test.describe.configure({ lock: "products" }) 是 1.63 的新功能。使用同一个锁名的测试,无论跨文件还是跨 worker,都不会同时运行。给所有写商品的测试加上同一个锁,它们就不会互相踩数据,而套件的其余部分仍然并行运行。

我们的 CRUD 测试课程讲了每一步需要哪些检查,以及为什么删除两次也是有用的测试。

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()一次调用的 DNS、连接、请求和响应耗时
1.62signal用 AbortSignal 取消请求
1.62retryStrategy: "isolated"把重试放到最后,一个一个地运行

这个文件测试了其中与请求相关的几项。我们的 /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 用于 action 和断言。其实它在请求方法上也能用,我们在 1.64 的类型定义和上面的测试里都确认过:?delay=3000 让我们的 API 等 3 秒,AbortSignal.timeout(500) 半秒后就放弃。请求自带的 timeout 选项作用类似;当由别的东西决定何时停止,比如测试提前结束时,signal 就更有用。

开头的配置已经用了 retryStrategy: "isolated"。默认情况下,失败的测试会在有空闲 worker 时立即重试,此时套件的其余部分还在运行。isolated 重试会等到最后,一个一个地运行。如果某个测试在并行运行中失败、在隔离重试中却通过, 很可能是别的测试干扰了它,比如两个测试在改同一条记录,这时用 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)

整个套件大约两秒跑完。下载 playwright-api-examples.zip,运行 npm install 和 npx playwright test,它就会针对同一个 API 运行。它用到的所有端点都是免费的,不需要账号或密钥。

在 UI 测试中调用 API

同一个客户端在浏览器测试里叫 page.request。它和页面共享 Cookie,所以你可以先通过 API 创建测试数据或登录,再在界面上检查结果,这比为每个测试点一遍表单来准备数据快得多:

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 按钮,可以在写测试之前先试一下每个请求。