Playwright API Testing 实战:用 APIRequestContext 直接调用 REST API(Node.js)
本文基于 Playwright 官方文档 API Testing(Node.js)整理并结合仓库源码展开,讲解如何不启动浏览器、直接从 Node.js 测试应用中发起 HTTP(S) 请求:包括配置 baseURL 与请求头、使用内置 request fixture 编写完整 API 测试套件、在 UI 测试中混用 API 请求准备前置条件与校验后置状态,以及 storageState 认证状态复用和上下文级/全局级两种 APIRequestContext 的 Cookie 隔离机制。读完后你将掌握用 Playwright 完成服务端 API 测试、测试数据准备与端到端状态校验的完整方案。
适用场景:为什么要在测试中直接请求 REST API
Playwright 的定位是 Web 测试与自动化框架(见 README.md),但它的 APIRequestContext 让测试代码可以直接访问应用的 REST API,而不必加载页面并在其中执行 JavaScript。官方文档指出三类典型场景:
- 直接测试服务端 API;
- 在访问 Web 应用测试前,先准备好服务端状态(建数据、开账号、清环境);
- 在浏览器中执行完一些操作后,通过 API 校验服务端后置条件。
以上全部能力都由 APIRequestContext 的一系列方法实现。
核心概念:APIRequest 与 APIRequestContext
从源码结构看,客户端侧的请求能力由 packages/playwright-core/src/client/fetch.ts 中的两个类承载:
APIRequest(L65-L94):入口对象,唯一职责是newContext(),即创建隔离的请求上下文。Playwright Test 的requestfixture 和playwright.request属性最终都指向它;APIRequestContext(L96 起):真正发送请求的对象,每个上下文拥有独立的 Cookie 存储、请求头与存储状态,用完调用dispose()释放。
APIRequestContext 暴露了完整的 HTTP 方法封装,见 packages/playwright-core/src/client/fetch.ts:get / post / put / patch / delete / head 都只是对通用 fetch(urlOrRequest, options) 的方法封装,fetch 还额外支持直接转发一个 Request 对象(这在 route 拦截转发场景中非常有用,下文会用到)。
fetch 的完整选项定义在 packages/playwright-core/src/client/fetch.ts:
| 选项 | 说明 |
|---|---|
params |
查询参数,接受对象、URLSearchParams 或已编码字符串 |
method |
HTTP 方法 |
headers |
请求头 |
data |
请求体:字符串(按 JSON content-type 处理)、Buffer 或可序列化对象(自动 JSON.stringify) |
form |
表单编码(application/x-www-form-urlencoded) |
multipart |
multipart/form-data,支持文件流 |
timeout |
本次请求超时,覆盖上下文默认超时 |
signal |
AbortSignal,用于取消请求 |
failOnStatusCode |
非 2xx/3xx 时抛错 |
ignoreHTTPSErrors |
忽略 HTTPS 证书错误 |
maxRedirects |
重定向上限,默认跟随重定向 |
maxRetries |
失败重试次数 |
源码中的 _innerFetch(L179-L270)还包含几个实用断言:data / form / multipart 三者只能指定其一;data 为字符串且 Content-Type 是 JSON 时会按 JSON 处理,非 JSON 类型则按 UTF-8 二进制发送。
配置:baseURL、extraHTTPHeaders 与代理
以测试 GitHub API 为例。GitHub API 要求鉴权,因此要为所有请求统一配置 token;同时设置 baseURL 后测试里就可以写相对路径。这些选项可以放在配置文件里,也可以在测试文件里用 test.use():
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}`,
},
}
});
仓库自带的完整示例在 examples/github-api/tests/test-api.spec.ts,它把同样的选项写在了测试文件中:
test.use({
baseURL: 'https://api.github.com',
extraHTTPHeaders: {
'Accept': 'application/vnd.github.v3+json',
// Add authorization token to all requests.
'Authorization': `token ${process.env.API_TOKEN}`,
}
});
该示例的运行配置见 examples/github-api/playwright.config.ts,其中 testDir: './tests'、timeout: 30 * 1000、reporter: 'html',是标准的 @playwright/test 项目结构,可直接复制改造。
代理配置
如果测试需要走代理,在配置文件中指定 proxy 后,request fixture 会自动继承:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
proxy: {
server: 'http://my-proxy:8080',
username: 'user',
password: 'secret'
},
}
});
编写 API 测试:内置 request fixture
Playwright Test 自带 request fixture,它会自动读取上面配置的 baseURL、extraHTTPHeaders、proxy 等选项,开箱即用。以下测试在 GitHub 仓库中创建 issue 并校验服务端状态:
const REPO = 'test-repo-1';
const USER = 'github-username';
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();
const issues = await request.get(`/repos/${USER}/${REPO}/issues`);
expect(issues.ok()).toBeTruthy();
expect(await issues.json()).toContainEqual(expect.objectContaining({
title: '[Bug] report 1',
body: 'Bug description'
}));
});
test('should create a feature request', async ({ request }) => {
const newIssue = await request.post(`/repos/${USER}/${REPO}/issues`, {
data: {
title: '[Feature] request 1',
body: 'Feature description',
}
});
expect(newIssue.ok()).toBeTruthy();
const issues = await request.get(`/repos/${USER}/${REPO}/issues`);
expect(issues.ok()).toBeTruthy();
expect(await issues.json()).toContainEqual(expect.objectContaining({
title: '[Feature] request 1',
body: 'Feature description'
}));
});
测试前后的 setup / teardown
上面的测试假设仓库已存在。通常希望在跑测试前先建仓库、跑完后删掉,用 beforeAll / afterAll 钩子完成:
test.beforeAll(async ({ request }) => {
// Create a new repository
const response = await request.post('/user/repos', {
data: {
name: REPO
}
});
expect(response.ok()).toBeTruthy();
});
test.afterAll(async ({ request }) => {
// Delete the repository
const response = await request.delete(`/repos/${USER}/${REPO}`);
expect(response.ok()).toBeTruthy();
});
这正是 examples/github-api/tests/test-api.spec.ts 的做法:beforeAll 中 POST /user/repos 建仓,afterAll 中 DELETE /repos/{user}/{repo} 清理。
request fixture 的源码实现
request fixture 的定义在 packages/playwright/src/index.ts,它的行为值得注意:
request: async ({ playwright }, use) => {
const request = await playwright.request.newContext();
await use(request);
const hook = (test.info() as TestInfoImpl)._currentHookType();
if (hook === 'beforeAll') {
await request.dispose({ reason: [
`Fixture { request } from beforeAll cannot be reused in a test.`,
` - Recommended fix: use a separate { request } in the test.`,
` - Alternatively, manually create APIRequestContext in beforeAll and dispose it in afterAll.`,
...
].join('\n') });
} else {
await request.dispose();
}
},
两点关键信息:
- 如官方文档所述,fixture 背后实际调用的是
playwright.request.newContext()(底层即 packages/playwright-core/src/client/fetch.ts 的APIRequest.newContext),因此use配置里的选项全部生效; - 在
beforeAll里拿到的requestfixture 不能直接用在测试体内——fixture 生命周期到钩子结束就 dispose 了,源码里甚至内置了针对性报错,提示你“在 beforeAll 手动创建 APIRequestContext 并在 afterAll 里 dispose”,这正好对应下文“UI 测试中发 API 请求”的写法。
手动创建请求上下文:独立脚本场景
如果需要更多控制权,可以绕过 fixture 手动调用 request.newContext()。下面这个独立脚本实现了与上文 beforeAll / afterAll 相同的建仓、删仓逻辑,不依赖测试框架:
import { request } from '@playwright/test';
const REPO = 'test-repo-1';
const USER = 'github-username';
(async () => {
// Create a context that will issue http requests.
const context = await request.newContext({
baseURL: 'https://api.github.com',
});
// Create a repository.
await context.post('/user/repos', {
headers: {
'Accept': 'application/vnd.github.v3+json',
// Add GitHub personal access token.
'Authorization': `token ${process.env.API_TOKEN}`,
},
data: {
name: REPO
}
});
// Delete a repository.
await context.delete(`/repos/${USER}/${REPO}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
'Authorization': `token ${process.env.API_TOKEN}`,
}
});
})();
对照源码可以确认 newContext 的行为细节(packages/playwright-core/src/client/fetch.ts):storageState 若传文件路径会被读成 JSON 对象后下发;extraHTTPHeaders 会被转成协议头数组;默认超时取 Playwright 实例的默认上下文超时。手动创建后请记得 dispose()——dispose 实现见 L116-L129,它会先导出已挂起的 HAR 再关闭通道。
在 UI 测试中发 API 请求
浏览器测试中同样需要调用后端 API:比如在跑用例前通过 API 准备数据,或在浏览器操作后回服务端校验状态。由于上文提到的 request fixture 在 beforeAll 中会被销毁,这里的推荐做法是:在 beforeAll 里手动创建 APIRequestContext 存到文件级变量,afterAll 中 dispose,测试体内直接使用。
建立前置条件(Preconditions)
以下测试先用 API 创建一个 issue,再导航到 issue 列表页断言它排在列表顶部:
import { test, expect } from '@playwright/test';
const REPO = 'test-repo-1';
const USER = 'github-username';
// Request context is reused by all tests in the file.
let apiContext;
test.beforeAll(async ({ playwright }) => {
apiContext = await playwright.request.newContext({
// All requests we send go to this API endpoint.
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();
});
test('last created issue should be first in the list', async ({ page }) => {
const newIssue = await apiContext.post(`/repos/${USER}/${REPO}/issues`, {
data: {
title: '[Feature] request 1',
}
});
expect(newIssue.ok()).toBeTruthy();
await page.goto(`https://github.com/${USER}/${REPO}/issues`);
const firstIssue = page.locator(`a[data-hovercard-type='issue']`).first();
await expect(firstIssue).toHaveText('[Feature] request 1');
});
校验后置条件(Postconditions)
反过来,也可以在浏览器 UI 中完成操作后,用 API 验证服务端真的落库了:
import { test, expect } from '@playwright/test';
const REPO = 'test-repo-1';
const USER = 'github-username';
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 ({ }) => {
await apiContext.dispose();
});
test('last created issue should be on the server', async ({ page }) => {
await page.goto(`https://github.com/${USER}/${REPO}/issues`);
await page.getByText('New Issue').click();
await page.getByRole('textbox', { name: 'Title' }).fill('Bug report 1');
await page.getByRole('textbox', { name: 'Comment body' }).fill('Bug description');
await page.getByText('Submit new issue').click();
const issueId = new URL(page.url()).pathname.split('/').pop();
const newIssue = await apiContext.get(
`https://api.github.com/repos/${USER}/${REPO}/issues/${issueId}`
);
expect(newIssue.ok()).toBeTruthy();
expect(newIssue.json()).toEqual(expect.objectContaining({
title: 'Bug report 1'
}));
});
注意这里的 apiContext 是全局级上下文(由 playwright.request.newContext() 创建),与 page 所属的 BrowserContext 不共享 Cookie——这引出下一节的两种上下文对比。
复用认证状态:storageState
Web 应用常用 Cookie 或 Token 认证,登录态最终都沉淀为 Cookie。Playwright 提供 APIRequestContext.storageState 方法,可以从已认证的上下文取回存储状态,再用它创建新上下文。
关键点:存储状态在 BrowserContext 与 APIRequestContext 之间可以互换。你可以纯用 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' });
源码层面,storageState 实现见 packages/playwright-core/src/client/fetch.ts:它从协议通道取回状态,若指定了 path 会把状态以格式化 JSON 写入文件。而 newContext 侧(L73-L93)接受 storageState 为文件路径字符串或对象,字符串时自动读取解析——两边正好闭环,这也是“API 登录 → 浏览器复用会话”方案能成立的原因。
上下文级请求 vs 全局级请求
APIRequestContext 有两种形态,这是理解 Cookie 行为差异的关键:
- 与
BrowserContext关联的上下文:通过context.request(BrowserContext.request属性)或page.request访问; - 隔离的全局实例:通过
playwright.request.newContext()(即APIRequest.newContext)创建。
两者的核心区别:BrowserContext.request / Page.request 发出的请求会带上浏览器上下文的 Cookie(自动填充 Cookie 头),并且当响应的 Set-Cookie 头到达时,浏览器上下文的 Cookie 会被同步更新;全局实例则拥有独立 Cookie 存储,互不影响。
官方文档给出的第一个验证用例(上下文级请求共享 Cookie):
test('context request will share cookie storage with its browser context', async ({
page,
context,
}) => {
await context.route('https://www.github.com/', async route => {
// Send an API request that shares cookie storage with the browser context.
const response = await context.request.fetch(route.request());
const responseHeaders = response.headers();
// The response will have 'Set-Cookie' header.
const responseCookies = new Map(responseHeaders['set-cookie']
.split('\n')
.map(c => c.split(';', 2)[0].split('=')));
// The response will have 3 cookies in 'Set-Cookie' header.
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// The browser context will already contain all the cookies from the API response.
expect(new Map(contextCookies.map(({ name, value }) =>
[name, value])
)).toEqual(responseCookies);
await route.fulfill({
response,
headers: { ...responseHeaders, foo: 'bar' },
});
});
await page.goto('https://www.github.com/');
});
用例技巧值得学习:用 context.route 拦截导航请求,在拦截器内部调 context.request.fetch(route.request()) 把同一请求转发为 API 请求,再把响应 route.fulfill 回给页面——这样既走了 API 通道,又不影响页面加载。
第二个用例则验证全局上下文的隔离性,并演示如何手动把 Cookie 从 API 侧“搬运”到浏览器侧:
test('global context request has isolated cookie storage', async ({
page,
context,
browser,
playwright
}) => {
// Create a new instance of APIRequestContext with isolated cookie storage.
const request = await playwright.request.newContext();
await context.route('https://www.github.com/', async route => {
const response = await request.fetch(route.request());
const responseHeaders = response.headers();
const responseCookies = new Map(responseHeaders['set-cookie']
.split('\n')
.map(c => c.split(';', 2)[0].split('=')));
// The response will have 3 cookies in 'Set-Cookie' header.
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// The browser context will not have any cookies from the isolated API request.
expect(contextCookies.length).toBe(0);
// Manually export cookie storage.
const storageState = await request.storageState();
// Create a new context and initialize it with the cookies from the global request.
const browserContext2 = await browser.newContext({ storageState });
const contextCookies2 = await browserContext2.cookies();
// The new browser context will already contain all the cookies from the API response.
expect(
new Map(contextCookies2.map(({ name, value }) => [name, value]))
).toEqual(responseCookies);
await route.fulfill({
response,
headers: { ...responseHeaders, foo: 'bar' },
});
});
await page.goto('https://www.github.com/');
await request.dispose();
});
对照这两个用例可以总结出选择策略:想让 API 请求与页面共享登录态(例如在同一会话中前后端联动操作),使用 context.request / page.request;想做独立的数据准备或无副作用的校验(不希望污染浏览器 Cookie),使用 playwright.request.newContext() 创建隔离实例,必要时用 storageState 显式传递状态。
小结
- Playwright Test 的
requestfixture 自动继承use配置(baseURL、extraHTTPHeaders、proxy),底层是APIRequest.newContext()(packages/playwright/src/index.ts); APIRequestContext提供 get/post/put/patch/delete/head/fetch 全量方法,请求体支持 JSON、Buffer、form 与 multipart 四种形态(packages/playwright-core/src/client/fetch.ts);- 独立脚本可用
request.newContext()手动管理上下文,记得dispose(); - UI 测试中发 API 请求时,推荐在
beforeAll手动创建上下文、afterAlldispose,用于前置数据准备与后置状态断言; storageState可在 API 上下文与浏览器上下文之间互换,实现“API 登录、浏览器免登”;context.request与浏览器共享 Cookie 存储,playwright.request.newContext()创建的实例则完全隔离,可按需选择。
完整可运行的示例位于 examples/github-api/(playwright.config.ts 与 test-api.spec.ts),可作为你项目 API 测试套件的起点。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00