Skip to main content

Тестирование 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 = 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'
}));
});

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

Веб-приложения используют аутентификацию на основе куки или токенов, где аутентифицированное состояние хранится в виде куки. 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();
});