Skip to content

介绍如何使用 APIRequestContext 发送请求、断言接口响应并复用认证状态。

Playwright 不仅可以驱动浏览器进行端到端测试,也可以直接访问应用程序的 REST API。

有时你可能希望直接从 Node.js 向服务器发送请求,而不是先打开页面再在页面中执行 JavaScript。常见场景包括:

  • 测试服务端 API。
  • 在访问 Web 应用之前,先准备服务端状态。
  • 在浏览器中完成某些操作后,校验服务端的后置状态。

这些场景都可以通过 APIRequestContext 提供的方法完成。

APIRequestContext 可以通过网络发送各种 HTTP(S) 请求。

下面的示例演示如何使用 Playwright 通过 GitHub API 测试 issue 的创建流程。这个测试套件会执行以下操作:

  • 在测试运行前创建一个新的仓库。
  • 创建多个 issue,并校验服务端状态。
  • 在测试结束后删除该仓库。

GitHub API 需要授权,因此可以为所有测试统一配置 token。同时,也可以设置 baseURL,让测试代码中的请求路径更简洁。你可以在配置文件中设置这些选项,也可以在测试文件中通过 test.use() 设置。

playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// 所有发送的请求都会指向这个 API 端点。
baseURL: 'https://api.github.com',
extraHTTPHeaders: {
// 按照 GitHub 的建议设置该请求头。
'Accept': 'application/vnd.github.v3+json',
// 为所有请求添加授权 token。
// 假设个人访问令牌已经通过环境变量提供。
'Authorization': `token ${process.env.API_TOKEN}`,
},
}
});

如果测试需要在代理后面运行,可以在配置中指定代理信息,request fixture 会自动使用该配置:

playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
proxy: {
server: 'http://my-proxy:8080',
username: 'user',
password: 'secret'
},
}
});

Playwright Test 内置了 request fixture。它会遵循前面配置的 baseURLextraHTTPHeaders 等选项,并且可以直接发送请求。

现在可以添加几个测试,用来在仓库中创建新的 issue。

import { test, expect } from '@playwright/test';
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'
}));
});

上面的测试假设仓库已经存在。通常你会希望在测试运行前创建一个新的仓库,并在测试结束后删除它。可以使用 beforeAllafterAll 钩子完成这些工作。

import { test, expect } from '@playwright/test';
const REPO = 'test-repo-1';
const USER = 'github-username';
test.beforeAll(async ({ request }) => {
// 创建一个新的仓库。
const response = await request.post('/user/repos', {
data: {
name: REPO
}
});
expect(response.ok()).toBeTruthy();
});
test.afterAll(async ({ request }) => {
// 删除该仓库。
const response = await request.delete(`/repos/${USER}/${REPO}`);
expect(response.ok()).toBeTruthy();
});

在内部,request fixture 实际上会调用 apiRequest.newContext()。如果你需要更细粒度的控制,也可以手动创建请求上下文。下面是一个独立脚本,它实现了和上面 beforeAllafterAll 类似的操作。

import { request } from '@playwright/test';
const REPO = 'test-repo-1';
const USER = 'github-username';
(async () => {
// 创建一个用于发送 HTTP 请求的上下文。
const context = await request.newContext({
baseURL: 'https://api.github.com',
});
// 创建仓库。
await context.post('/user/repos', {
headers: {
'Accept': 'application/vnd.github.v3+json',
// 添加 GitHub 个人访问令牌。
'Authorization': `token ${process.env.API_TOKEN}`,
},
data: {
name: REPO
}
});
// 删除仓库。
await context.delete(`/repos/${USER}/${REPO}`, {
headers: {
'Accept': 'application/vnd.github.v3+json',
// 添加 GitHub 个人访问令牌。
'Authorization': `token ${process.env.API_TOKEN}`,
}
});
})();

在浏览器中运行测试时,你可能也需要调用应用程序的 HTTP API。例如,在测试开始前准备服务端状态,或者在浏览器操作完成后检查服务端状态。此类操作同样可以通过 APIRequestContext 完成。

下面的测试先通过 API 创建一个新的 issue,然后进入项目的 issue 列表页面,并验证它是否出现在列表顶部。

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({
// 所有发送的请求都会指向这个 API 端点。
baseURL: 'https://api.github.com',
extraHTTPHeaders: {
// 按照 GitHub 的建议设置该请求头。
'Accept': 'application/vnd.github.v3+json',
// 为所有请求添加授权 token。
// 假设个人访问令牌已经通过环境变量提供。
'Authorization': `token ${process.env.API_TOKEN}`,
},
});
});
test.afterAll(async ({ }) => {
// 释放所有响应。
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');
});

下面的测试先在浏览器 UI 中创建一个新的 issue,然后通过 API 检查该 issue 是否已经在服务端创建成功。

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({
// 所有发送的请求都会指向这个 API 端点。
baseURL: 'https://api.github.com',
extraHTTPHeaders: {
// 按照 GitHub 的建议设置该请求头。
'Accept': 'application/vnd.github.v3+json',
// 为所有请求添加授权 token。
// 假设个人访问令牌已经通过环境变量提供。
'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'
}));
});

Web 应用通常使用基于 cookie 或 token 的认证方式,认证状态会保存在 cookie 中。Playwright 提供了 apiRequestContext.storageState() 方法,可以从已经认证的请求上下文中获取存储状态,并用该状态创建新的上下文。

存储状态可以在 BrowserContextAPIRequestContext 之间互相使用。你可以通过 API 调用完成登录,然后创建一个已经带有 cookie 的浏览器上下文。下面的代码从已认证的 APIRequestContext 获取状态,并使用该状态创建新的 BrowserContext

const requestContext = await request.newContext({
httpCredentials: {
username: 'user',
password: 'passwd'
}
});
await requestContext.get(`https://api.example.com/login`);
// 将存储状态保存到文件。
await requestContext.storageState({ path: 'state.json' });
// 使用保存的存储状态创建新的浏览器上下文。
const context = await browser.newContext({ storageState: 'state.json' });

APIRequestContext 有两种类型:

  • BrowserContext 关联的请求上下文。
  • 通过 apiRequest.newContext() 创建的隔离实例。

主要区别在于,通过 browserContext.requestpage.request 访问到的 APIRequestContext 会从浏览器上下文中填充请求的 Cookie 请求头;如果 APIResponse 中包含 Set-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 => {
// 发送一个与浏览器上下文共享 cookie 存储的 API 请求。
const response = await context.request.fetch(route.request());
const responseHeaders = response.headers();
// 响应中会包含 'Set-Cookie' 头。
const responseCookies = new Map(responseHeaders['set-cookie']
.split('\n')
.map(c => c.split(';', 2)[0].split('=')));
// 响应的 'Set-Cookie' 头中会包含 3 个 cookie。
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// 浏览器上下文中已经包含 API 响应中的所有 cookie。
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/');
});

如果不希望 APIRequestContext 使用或更新浏览器上下文中的 cookie,可以手动创建一个新的 APIRequestContext 实例。这个实例会拥有独立的 cookie 存储。

test('global context request has isolated cookie storage', async ({
page,
context,
browser,
playwright
}) => {
// 创建一个拥有独立 cookie 存储的 APIRequestContext 实例。
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('=')));
// 响应的 'Set-Cookie' 头中会包含 3 个 cookie。
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// 浏览器上下文不会包含隔离 API 请求返回的 cookie。
expect(contextCookies.length).toBe(0);
// 手动导出 cookie 存储。
const storageState = await request.storageState();
// 创建新的上下文,并使用全局请求中的 cookie 初始化它。
const browserContext2 = await browser.newContext({ storageState });
const contextCookies2 = await browserContext2.cookies();
// 新的浏览器上下文中已经包含 API 响应中的所有 cookie。
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();
});
-
0:000:00