Перейти к основному содержимому

Тестирование API

Введение​

Playwright может быть использован для доступа к REST API вашего приложения.

Иногда может возникнуть необходимость отправить запросы на сервер напрямую из Node.js без загрузки страницы и выполнения js-кода в ней. Несколько примеров, когда это может быть полезно:

  • Тестирование API вашего сервера.
  • Подготовка состояния на стороне сервера перед посещением веб-приложения в тесте.
  • Проверка постусловий на стороне сервера после выполнения некоторых действий в браузере.

Все это можно достичь с помощью методов APIRequestContext.

Написание теста API​

APIRequestContext может отправлять все виды HTTP(S) запросов по сети.

Следующий пример демонстрирует, как использовать Playwright для тестирования создания задач через GitHub API. Набор тестов будет выполнять следующее:

  • Создавать новый репозиторий перед запуском тестов.
  • Создавать несколько задач и проверять состояние сервера.
  • Удалять репозиторий после выполнения тестов.

Конфигурация​

GitHub API требует авторизации, поэтому мы настроим токен один раз для всех тестов. Заодно мы также установим 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',
// Добавляем токен авторизации ко всем запросам.
// Предполагается, что личный токен доступа доступен в окружении.
'Authorization': `token ${process.env.API_TOKEN}`,
},
}
});

Конфигурация прокси

Если ваши тесты должны выполняться за прокси, вы можете указать это в конфигурации, и фикстура request автоматически это учтет:

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, которая учитывает такие параметры конфигурации, как 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'
}));
});

Настройка и завершение​

Эти тесты предполагают, что репозиторий существует. Вероятно, вы захотите создать новый перед запуском тестов и удалить его после. Используйте хуки beforeAll и afterAll для этого.

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(). Вы всегда можете сделать это вручную, если хотите больше контроля. Ниже приведен автономный скрипт, который делает то же самое, что и beforeAll и afterAll из выше.

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

Отправка API-запросов из UI-тестов​

Во время выполнения тестов внутри браузеров вы можете захотеть сделать вызовы к 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: {
// Мы устанавливаем этот заголовок согласно рекомендациям GitHub.
'Accept': 'application/vnd.github.v3+json',
// Добавляем токен авторизации ко всем запросам.
// Предполагается, что личный токен доступа доступен в окружении.
'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: {
// Мы устанавливаем этот заголовок согласно рекомендациям GitHub.
'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'
}));
});

Повторное использование состояния аутентификации​

Веб-приложения используют аутентификацию на основе куки или токенов, где аутентифицированное состояние хранится в виде куки. Playwright предоставляет метод apiRequestContext.storageState(), который может быть использован для получения состояния хранилища из аутентифицированного контекста, а затем создания новых контекстов с этим состоянием.

Состояние хранилища взаимозаменяемо между BrowserContext и APIRequestContext. Вы можете использовать его для входа через API-вызовы, а затем создать новый контекст с уже имеющимися куки. Следующий код извлекает состояние из аутентифицированного 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' });

Контекстный запрос vs глобальный запрос​

Существует два типа APIRequestContext:

Основное различие заключается в том, что APIRequestContext, доступный через browserContext.request и page.request, будет заполнять заголовок запроса Cookie из контекста браузера и автоматически обновлять куки браузера, если APIResponse имеет заголовок Set-Cookie:

test('context request will share cookie storage with its browser context', async ({
page,
context,
}) => {
await context.route('https://www.github.com/', async route => {
// Отправить 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('=')));
// Ответ будет иметь 3 куки в заголовке 'Set-Cookie'.
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// Контекст браузера уже будет содержать все куки из API-ответа.
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 использовал и обновлял куки из контекста браузера, вы можете вручную создать новый экземпляр APIRequestContext, который будет иметь свои собственные изолированные куки:

test('global context request has isolated cookie storage', async ({
page,
context,
browser,
playwright
}) => {
// Создать новый экземпляр 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('=')));
// Ответ будет иметь 3 куки в заголовке 'Set-Cookie'.
expect(responseCookies.size).toBe(3);
const contextCookies = await context.cookies();
// Контекст браузера не будет иметь никаких куки из изолированного API-запроса.
expect(contextCookies.length).toBe(0);

// Вручную экспортировать хранилище куки.
const storageState = await request.storageState();
// Создать новый контекст и инициализировать его куки из глобального запроса.
const browserContext2 = await browser.newContext({ storageState });
const contextCookies2 = await browserContext2.cookies();
// Новый контекст браузера уже будет содержать все куки из API-ответа.
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();
});