首页
/ Playwright API Testing 实战:用 APIRequestContext 直接调用 REST API(Node.js)

Playwright API Testing 实战:用 APIRequestContext 直接调用 REST API(Node.js)

2026-09-06 09:58:24作者:侯霆垣

本文基于 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 的 request fixture 和 playwright.request 属性最终都指向它;
  • APIRequestContext(L96 起):真正发送请求的对象,每个上下文拥有独立的 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 或已编码字符串
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 失败重试次数

源码中的 _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.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 * 1000reporter: '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,它会自动读取上面配置的 baseURLextraHTTPHeadersproxy 等选项,开箱即用。以下测试在 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 的做法:beforeAllPOST /user/repos 建仓,afterAllDELETE /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();
  }
},

两点关键信息:

  1. 如官方文档所述,fixture 背后实际调用的是 playwright.request.newContext()(底层即 packages/playwright-core/src/client/fetch.tsAPIRequest.newContext),因此 use 配置里的选项全部生效;
  2. beforeAll 里拿到的 request fixture 不能直接用在测试体内——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 存到文件级变量,afterAlldispose,测试体内直接使用。

建立前置条件(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 方法,可以从已认证的上下文取回存储状态,再用它创建新上下文。

关键点:存储状态在 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' });

源码层面,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 存储,互不影响。

官方文档给出的第一个验证用例(上下文级请求共享 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 的 request fixture 自动继承 use 配置(baseURLextraHTTPHeadersproxy),底层是 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 手动创建上下文、afterAll dispose,用于前置数据准备与后置状态断言;
  • storageState 可在 API 上下文与浏览器上下文之间互换,实现“API 登录、浏览器免登”;
  • context.request 与浏览器共享 Cookie 存储,playwright.request.newContext() 创建的实例则完全隔离,可按需选择。

完整可运行的示例位于 examples/github-api/playwright.config.tstest-api.spec.ts),可作为你项目 API 测试套件的起点。

登录后查看全文
热门项目推荐
相关项目推荐