API 测试

介绍

Playwright 可以用来访问您应用程序的 【REST】 API。

有时,您可能希望直接从 Node.js 发送请求到服务器,而无需加载页面和在其中运行 JavaScript 代码。一些可能用到的场景包括:

  • 测试您的服务器 API。

  • 在测试中访问 Web 应用之前准备服务器端状态。

  • 在浏览器中执行某些操作后验证服务器端的后置条件。

所有这些都可以通过 【APIRequestContext】 方法实现。

编写 API 测试

【APIRequestContext】 可以发送各种 HTTP(S) 请求。

以下示例演示了如何使用 Playwright 测试通过 【GitHub API】 创建问题。测试套件将执行以下操作:

  • 在运行测试之前创建一个新仓库。

  • 创建几个问题并验证服务器状态。

  • 在测试结束后删除仓库。

配置

GitHub API 需要授权,因此我们将为所有测试配置一次 token。同时,我们还会设置 baseURL 以简化测试。您可以将这些配置放在配置文件中,或者在测试文件中使用 test.use()

playwright.config.ts
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}`,
    },
  }
});

代理配置

如果您的测试需要通过代理运行,可以在配置中指定,测试框架将自动使用该代理。

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    proxy: {
      server: 'http://my-proxy:8080',
      username: 'user',
      password: 'secret'
    },
  }
});

编写测试

Playwright Test 自带了一个请求 fixture,能够尊重我们在配置中指定的选项,如 baseURL 或 extraHTTPHeaders,并准备好发送请求。

现在我们可以添加一些测试,创建新的问题。

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 钩子来完成此操作。

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 个人访问 token
      'Authorization': `token ${process.env.API_TOKEN}`,
    },
    data: {
      name: REPO
    }
  });

  // 删除一个仓库
  await context.delete(`/repos/${USER}/${REPO}`, {
    headers: {
      'Accept': 'application/vnd.github.v3+json',
      // 添加 GitHub 个人访问 token
      'Authorization': `token ${process.env.API_TOKEN}`,
    }
  });
})();

从 UI 测试中发送 API 请求

在浏览器中运行测试时,您可能希望调用应用程序的 HTTP API。如果您需要在运行测试之前准备服务器状态,或在浏览器中执行一些操作后检查服务器的后置条件,这可能非常有用。所有这些都可以通过 APIRequestContext 方法来实现。

建立前置条件

以下测试通过 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({
    // 所有请求将发送到这个 API 端点。
    baseURL: 'https://api.github.com',
    extraHTTPHeaders: {
      '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');
});

验证后置条件

以下测试通过浏览器界面创建一个新问题,然后检查它是否通过 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({
    // 所有请求将发送到这个 API 端点。
    baseURL: 'https://api.github.com',
    extraHTTPHeaders: {
      '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 = page.url().substr(page.url().lastIndexOf('/'));

  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 或令牌的身份验证,其中认证状态存储在 Cookies 中。Playwright 提供了 apiRequestContext.storageState() 方法,可以用来从已认证的上下文中获取存储状态,然后使用该状态创建新的上下文。

存储状态可以在 BrowserContextAPIRequestContext 之间互换。你可以通过 API 调用进行登录,然后创建一个新的上下文,已经包含了 Cookies。以下代码片段从已认证的 APIRequestContext 中检索存储状态,并使用该状态创建一个新的 BrowserContext

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' });

上下文请求与全局请求

有两种类型的 APIRequestContext

  1. BrowserContext 关联的请求

  2. 隔离的实例,通过 apiRequest.newContext() 创建

它们的主要区别在于,通过 browserContext.requestpage.request 访问的 APIRequestContext 会从浏览器上下文填充请求的 Cookie 头,并且如果 APIResponse 包含 Set-Cookie 头,它会自动更新浏览器的 Cookies。

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/');
});

如果你不希望 APIRequestContext 使用并更新来自浏览器上下文的 Cookies,你可以手动创建一个新的 APIRequestContext 实例,它将拥有自己的隔离的 Cookies。

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();
});