用 Playwright 做接口测试:不开浏览器也能跑

2026-08-18

写 E2E 的人多半都遇到过这个场景:一条用例真正想验的是”列表里第一条是刚提交的那条”,但为了造出那条数据,得先点开新建、填三个输入框、等页面跳转。造数据的那几十行点击既慢又脆,坏了还得排查是造数据坏了还是被测功能坏了。

Playwright 仓库的 docs/src/api-testing-js.md 开篇就把这件事写明了:有时候你想直接从 Node.js 往服务端发请求,而不加载页面、不在页面里跑 js。它列的三个用途分别是测服务端 API、在访问 web 应用前预置服务端状态、在浏览器里操作完之后校验服务端的后置条件。这三件事都走 APIRequestContext 的方法。

问题在于,同一套工程里能拿到 APIRequestContext 的入口不止一个,而它们的行为并不相同。这篇就把入口之间的差别和鉴权头的落点说清楚。

前置条件

request fixture 属于 Playwright Test Runner,也就是 @playwright/testdocs/src/test-api/class-fixtures.mdFixtures.request 标注 since: v1.10,类型是 APIRequestContext,一句话说明是”Isolated APIRequestContext instance for each test”——每条测试一个隔离实例。

APIRequestAPIRequestContext 这两个类本身,在 docs/src/api/class-apirequest.mddocs/src/api/class-apirequestcontext.md 里都标注 since: v1.16APIRequest 的实例通过 Playwright.request 属性拿到,它只有一个方法 newContext()

语言绑定这里要看清楚,别照搬。docs/src/api-testing-js.md 是 JS 专属页,request fixture 是 Test Runner 提供的。而 docs/src/api-testing-python.md 的示例里没有用同名内置 fixture,它是自己写了一个 session 作用域的 pytest fixture,在里面调 playwright.request.new_context(base_url=..., extra_http_headers=headers),方法名是蛇形的 new_context。该页开头写明示例依赖 pytest-playwright 包。下面的代码都是 JS 侧的写法。

第一步:把 baseURL 和鉴权头放进配置

仓库文档的做法是把 token 配一次给所有测试用,顺手把 baseURL 也设了:

import { defineConfig } from '@playwright/test';
export default defineConfig({
  use: {
    // All requests we send go to this API endpoint.
    baseURL: 'https://api.github.com',
    extraHTTPHeaders: {
      // We set this header per GitHub guidelines.
      'Accept': 'application/vnd.github.v3+json',
      // Add authorization token to all requests.
      // Assuming personal access token available in the environment.
      'Authorization': `token ${process.env.API_TOKEN}`,
    },
  }
});

这段是仓库文件里的原文,里面的 https://api.github.comtoken ${...} 只是该示例针对 GitHub API 的取值,换成你自己的服务时按你的服务要求写。文档同时提到,这两项也可以不写在配置文件里,而是在测试文件里用 test.use() 设置。

extraHTTPHeaders 的语义在 docs/src/api/params.mdcontext-option-extrahttpheaders 里:一个对象,包含要随每个请求发送的额外 HTTP 头,默认没有。也就是说 Authorization 放这里,是”这一层的所有请求都带”的意思。

环境变量本身怎么设是操作系统的事,不是仓库内容:Linux/macOS 下 export API_TOKEN=...,Windows PowerShell 下 $env:API_TOKEN="...",两边写法不通用。这属于通用做法,仓库文档没有规定。真实 token 不要写进配置文件跟着代码提交。

如果测试要走代理,文档给的是在 use 里配 proxy,并写明 request fixture 会自动取用。

第二步:在用例里用 request fixture

test('should create a bug report', async ({ request }) => {
  const newIssue = await request.post(`/repos/${USER}/${REPO}/issues`, {
    data: {
      title: '[Bug] report 1',
      body: 'Bug description',
    }
  });
  expect(newIssue.ok()).toBeTruthy();
});

data 选项的语义在 params.mdjs-python-csharp-fetch-option-data 里写得很明确:如果 data 是对象,会被序列化成 json 字符串,并且在没有显式设置的情况下把 content-type 设为 application/json;否则在没有显式设置时设为 application/octet-stream。这一条值得记住,因为很多人以为要自己拼 JSON 和加头。

那么配置里的 baseURLextraHTTPHeaders 是怎么进到这个 fixture 里的?packages/playwright/src/index.tsrequest fixture 的实现体其实是 await playwright.request.newContext(),一个参数都没传。真正做合并的是同一个文件里 _setupArtifacts 注册的那个 instrumentation 监听器,它有一个 runBeforeCreateRequestContext 回调,把 _combinedContextOptions 的每一项遍历着补进即将创建的请求上下文选项里,并且带一句 if (!(key in options)) 的判断——已经显式传过的键不会被覆盖。这段循环没有对键名做白名单过滤,只判断键在不在。

知道这条机制,两个常见困惑就有解释了:为什么 newContext() 明明没传参却带上了配置里的头;以及为什么你手动传的同名选项优先级更高。

第三步:需要更多控制时手动建上下文

docs/src/api-testing-js.md 写明,request fixture 背后实际调用的就是 APIRequest.newContext,你想要更多控制随时可以自己来。文档里预置数据的写法是在 beforeAll 里建、在 afterAll 里销毁:

let apiContext;

test.beforeAll(async ({ playwright }) => {
  apiContext = await playwright.request.newContext({
    baseURL: 'https://api.github.com',
    extraHTTPHeaders: {
      'Accept': 'application/vnd.github.v3+json',
      'Authorization': `token ${process.env.API_TOKEN}`,
    },
  });
});

test.afterAll(async ({ }) => {
  // Dispose all responses.
  await apiContext.dispose();
});

dispose() 不是可省的收尾动作。class-apirequestcontext.md 里写明:get() 等方法返回的所有响应都存在内存里,以便你之后还能调 APIResponse.body()dispose() 丢弃这些资源,而且在已 dispose 的上下文上调任何方法都会抛异常。disposereason 选项标注 since: v1.45,用于告知被这次销毁打断的操作原因。

三个入口的分工

class-apirequestcontext.md 的类说明把关系写得很直白:每个浏览器上下文都关联一个 APIRequestContext,通过 BrowserContext.requestPage.request 拿到,而这两个返回的是同一个实例page.requestpage.context().request 的快捷方式。这条来自浏览器上下文的请求上下文,跟浏览器共用同一个 cookie jar:每个发出去的 API 请求会自动带上该上下文的 cookie,响应里的 Set-Cookie 会写回浏览器上下文,通过 API 登录等于浏览器也登录了,反过来也一样。

APIRequest.newContext() 建出来的是独立实例,文档明说它有自己隔离的 cookie 存储。request fixture 走的正是这条路,所以它也是隔离的。

选哪个的判据其实只有一条:你这次请求要不要和浏览器共用登录态。要共用就用 page.request;不想让 API 请求污染浏览器 cookie,就用 fixture 或者自己建。

鉴权头的三个落点

除了配置层的 extraHTTPHeaders,还有两条路,语义各不相同。

一是单次请求的 headers 选项。class-apirequestcontext.mdget / post / delete 等方法都挂了 headers 选项,params.mdjs-python-csharp-fetch-option-headers 说明它设置的 HTTP 头会应用到本次请求以及由它发起的重定向。适合只有个别接口需要换 token 的情况。

二是 httpCredentials,走 HTTP 基本认证。它的定义在 params.mdcontext-option-httpcredentials,字段有 usernamepassword、可选的 origin(限制只对某个 scheme://host:port 发送凭据),以及一个容易踩的 send:取值 "unauthorized""always",只作用于 APIRequestContext 发出的请求、不影响浏览器发出的请求;'always' 表示每次 API 请求都带上基本认证的 Authorization 头,'unauthorized' 表示只有在收到带 WWW-Authenticate 的 401 响应后才发。文档写明默认是 'unauthorized'——这是仓库当前文档里的默认值,随版本可能变动。如果你的服务不回 401 而是直接回 403 或者 200 带空数据,用默认值就永远发不出凭据。文档还写明可以传数组来给不同 origin 用不同凭据,第一条匹配到的生效,没写 origin 的条目匹配任意请求。

第三条不算”头”,但常常是真正的答案:APIRequestContext.storageState()。文档写明 storage state 在 BrowserContextAPIRequestContext 之间是可互换的,可以先用 API 调用登录,再用这份状态创建一个 cookie 已就位的浏览器上下文:

const requestContext = await request.newContext({
  httpCredentials: {
    username: 'user',
    password: 'passwd'
  }
});
await requestContext.get(`https://api.example.com/login`);
// Save storage state into the file.
await requestContext.storageState({ path: 'state.json' });

// Create a new context with the saved storage state.
const context = await browser.newContext({ storageState: 'state.json' });

以上代码块出自仓库文档原文,我们未经实测,以仓库最新代码为准。storageStateindexedDB 选项标注 since: v1.51,设为 true 时把 IndexedDB 也纳入快照。

边界

beforeAll 里的 request fixture 不能带进 test。 这一条在 packages/playwright/src/index.ts 的 fixture 实现里有明确处置:如果当前 hook 类型是 beforeAll,它会用一个带 reason 的 dispose() 销毁掉,reason 文本首句是 Fixture { request } from beforeAll cannot be reused in a test.,并给了两条建议——在 test 里单独用一个 { request },或者手动在 beforeAllAPIRequestContext 并在 afterAll 销毁。也就是上一节那种写法。

非 2xx/3xx 不会自动失败。 failOnStatusCode 的说明写着:默认对所有状态码都返回响应对象。所以断言得自己写,比如文档里用的 expect(newIssue.ok()).toBeTruthy()。这个选项在 APIRequest.newContext 上标注 since: v1.51

重试不是你想的那种重试。 params.mdjs-python-csharp-fetch-option-maxretries 写明:maxRetries 是网络错误的最大重试次数,当前只重试 ECONNRESET不基于 HTTP 响应码重试,超限会抛错,默认 0 即不重试。这是仓库当前文档里的默认值,随版本可能变动。指望它兜住 502 的话方向就错了。

选项按语言分叉。 同一个方法在不同绑定下选项不一样:formmultipart 在 js、python、csharp 各有各的定义,csharp 侧的 form/multipart 收的是 FormData,要通过 APIRequestContext.createFormData() 创建,而这个方法在文档里标注 langs: csharpparams 在 js/python/csharp 是选项,在 java 侧则是一个 RequestOptions 类型的参数。写哪种语言就翻哪种语言的那段。

APIRequestContext.tracing 标注 since: v1.60,是这个请求上下文的 tracing 记录器。版本较老的工程里不要按这个写。

怎么验证配对了

第一件事是验 baseURL 拼接。文档在 APIRequest.newContext.baseURL 下给了三条并列的例子,说明拼接走的是 URL() 构造函数:http://localhost:3000 配请求 /bar.html 得到 http://localhost:3000/bar.htmlhttp://localhost:3000/foo/./bar.html 得到 http://localhost:3000/foo/bar.html;而 http://localhost:3000/foo(末尾没有斜杠)配 ./bar.html 得到的是 http://localhost:3000/bar.html。末尾那个斜杠的有无会改变结果,路径莫名少一段时先查这里。

第二件事是验鉴权头有没有真的发出去。最直接的证据是响应本身:APIResponse 上有 ok()status()statusText()headers()json()text()body(),都标注 since: v1.16。头配错时通常表现为鉴权失败的状态码,用 status()statusText() 看比猜快。

第三件事是验 cookie 归属对不对。用 storageState() 取回当前上下文的 cookie 与 localStorage 快照,对着看是不是你以为的那份。文档里”Context request vs global request”一节的两个用例正是这么写的:走 context.request 那条,浏览器上下文里会出现响应带回的 cookie;走 playwright.request.newContext() 那条,同样的请求之后浏览器上下文的 cookie 数组是空的。这也是判断你到底用了哪个入口的省事办法。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。