)
Playwright API Testing 实战用 APIRequestContext 直接调用 REST APINode.js【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwright本文基于 Playwright 官方文档 API TestingNode.js整理并结合仓库源码展开讲解如何不启动浏览器、直接从 Node.js 测试应用中发起 HTTP(S) 请求包括配置baseURL与请求头、使用内置requestfixture 编写完整 API 测试套件、在 UI 测试中混用 API 请求准备前置条件与校验后置状态以及storageState认证状态复用和上下文级/全局级两种APIRequestContext的 Cookie 隔离机制。读完后你将掌握用 Playwright 完成服务端 API 测试、测试数据准备与端到端状态校验的完整方案。适用场景为什么要在测试中直接请求 REST APIPlaywright 的定位是 Web 测试与自动化框架见 README.md但它的APIRequestContext让测试代码可以直接访问应用的 REST API而不必加载页面并在其中执行 JavaScript。官方文档指出三类典型场景直接测试服务端 API在访问 Web 应用测试前先准备好服务端状态建数据、开账号、清环境在浏览器中执行完一些操作后通过 API 校验服务端后置条件。以上全部能力都由 APIRequestContext 的一系列方法实现。核心概念APIRequest 与 APIRequestContext从源码结构看客户端侧的请求能力由 packages/playwright-core/src/client/fetch.ts 中的两个类承载APIRequestL65-L94入口对象唯一职责是newContext()即创建隔离的请求上下文。Playwright Test 的requestfixture 和playwright.request属性最终都指向它APIRequestContextL96 起真正发送请求的对象每个上下文拥有独立的 Cookie 存储、请求头与存储状态用完调用dispose()释放。APIRequestContext暴露了完整的 HTTP 方法封装见 packages/playwright-core/src/client/fetch.tsget/post/put/patch/delete/head都只是对通用fetch(urlOrRequest, options)的方法封装fetch还额外支持直接转发一个Request对象这在route拦截转发场景中非常有用下文会用到。fetch的完整选项定义在 packages/playwright-core/src/client/fetch.ts选项说明params查询参数接受对象、URLSearchParams或已编码字符串methodHTTP 方法headers请求头data请求体字符串按 JSON content-type 处理、Buffer 或可序列化对象自动JSON.stringifyform表单编码application/x-www-form-urlencodedmultipartmultipart/form-data支持文件流timeout本次请求超时覆盖上下文默认超时signalAbortSignal用于取消请求failOnStatusCode非 2xx/3xx 时抛错ignoreHTTPSErrors忽略 HTTPS 证书错误maxRedirects重定向上限默认跟随重定向maxRetries失败重试次数源码中的_innerFetchL179-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.v3json, // 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.v3json, // 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后requestfixture 会自动继承import { defineConfig } from playwright/test; export default defineConfig({ use: { proxy: { server: http://my-proxy:8080, username: user, password: secret }, } });编写 API 测试内置 request fixturePlaywright Test 自带requestfixture它会自动读取上面配置的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 的源码实现requestfixture 的定义在 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.v3json, // 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.v3json, Authorization: token ${process.env.API_TOKEN}, } }); })();对照源码可以确认newContext的行为细节packages/playwright-core/src/client/fetch.tsstorageState若传文件路径会被读成 JSON 对象后下发extraHTTPHeaders会被转成协议头数组默认超时取 Playwright 实例的默认上下文超时。手动创建后请记得dispose()——dispose实现见 L116-L129它会先导出已挂起的 HAR 再关闭通道。在 UI 测试中发 API 请求浏览器测试中同样需要调用后端 API比如在跑用例前通过 API 准备数据或在浏览器操作后回服务端校验状态。由于上文提到的requestfixture 在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.v3json, 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-typeissue]).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.v3json, 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——这引出下一节的两种上下文对比。复用认证状态storageStateWeb 应用常用 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.requestBrowserContext.request属性或page.request访问隔离的全局实例通过playwright.request.newContext()即APIRequest.newContext创建。两者的核心区别BrowserContext.request/Page.request发出的请求会带上浏览器上下文的 Cookie自动填充Cookie头并且当响应的Set-Cookie头到达时浏览器上下文的 Cookie 会被同步更新全局实例则拥有独立 Cookie 存储互不影响。官方文档给出的第一个验证用例上下文级请求共享 Cookietest(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.tsAPIRequestContext提供 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 测试套件的起点。【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwright创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考