Вступление
В данной статье мы разберем API автотесты на языке TypeScript. В качестве фреймворка выберем playwright.
Хочется, чтобы наши автотесты отвечали следующим требованиям:
Проверки должны быть полными, то есть мы должны проверить статус код ответа, данные в теле ответа, провалидировать JSON схему;
Подготовка тестовых данных должна быть на уровне фикстур;
Понятный и красивый отчет;
Requirements
Для написания API автотестов мы будем использовать:
playwright - yarn add playwright/npm install playwright;
allure-playwright - yarn add allure-playwright/npm install allure-playwright;
dotenv - yarn add dotenv /npm install dotenv, - для чтения настроек из .env файла;
ajv- yarn add ajv/npm install ajv, - для валидации JSON схемы;
Тесты будем писать на публичный API https://api.sampleapis.com/futurama/questions. Данный API всего лишь пример. На реальных проектах API может быть гораздо сложнее, но суть написания автотестов остается та же.
Settings
Добавим базовые настройки проекта в .env файл
CI=1 # For playwright
ENV_NAME="Local" # Name of our env just for example, can be "Dev", "Staging" etc.
ALLURE_RESULTS_FOLDER="allure-results" # Folder where allure results are stored
BASE_URL="https://api.sampleapis.com" # API endpoint
TEST_USER_EMAIL="some@gmail.com" # Some random user just for example
TEST_USER_PASSWORD="some" # Some random password just for example
Файл конфигурации playwright будет выглядеть стандартным образом, добавим лишь allure-report. Исключим ненужные для API тестов настройки, по типу projects, headless, video, screenshot. Более подробно про конфигурацию playwright можно почитать тут.
playwright.config.ts
import { defineConfig } from '@playwright/test';
import { config as dotenvConfig } from 'dotenv';
import { resolve } from 'path';
dotenvConfig({ path: resolve(__dirname, '.env'), override: true });
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [['html'], ['allure-playwright']],
globalTeardown: require.resolve('./utils/config/global-teardown'),
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
}
});
Обратим внимание на строчку:
dotenvConfig({ path: resolve(__dirname, '.env'), override: true });
Тут мы загружаем настройки и .env файла, который создавали ранее.
Лайфхак. Если у вас есть несколько окружений (dev, test, staging, local), то вы можете создать несколько файлов .env, например, .env.dev, .env.test, .env.staging, в каждый из них поместить настройки для определенного окружения. Тогда загрузка файла с настройками будет выглядеть примерно так:
dotenvConfig({ path: resolve(__dirname, process.env.ENV_FILE), override: true });
Переменную ENV_FILE
придется заранее добавить в окружение либо в команду запуска export ENV_FILE=".env.test" && npx playwright test
Types
Напишем типы для объекта question из API https://api.sampleapis.com/futurama/questions. Сам объект выглядит примерно так:
{
"id": 1,
"question": "What is Fry's first name?",
"possibleAnswers": [
"Fred",
"Philip",
"Will",
"John"
],
"correctAnswer": "Philip"
}
utils\types\api\questions.ts
export interface Question {
id: number;
question: string;
possibleAnswers: string[];
correctAnswer: string | number;
}
export interface UpdateQuestion extends Partial<Omit<Question, 'id'>> {}
utils\types\api\authentication.ts
export interface AuthUser {
email: string;
password: string;
}
export interface APIAuth {
authToken?: string;
user?: AuthUser;
}
AuthUser
возьмем просто для примера (на вашем проекте могут быть другие требования для аутентификации).
utils\types\api\client.ts
import { APIRequestContext } from '@playwright/test';
export interface APIClient {
context: APIRequestContext;
}
APIClient
понадобится нам для имплементации API клиентов. Об этом поговорим ниже, когда будем описывать клиенты.
Context
У playwright есть понятие контекста, который может использоваться для выполнения API запросов. С помощью контекста мы можем выставлять baseURL, заголовки, например, токен авторизации, proxy, timeout, подробнее почитайте тут.
Сначала напишем базовый контекст, который будет использоваться для всех запросов без авторизации
core\context\default-context.ts
import { request } from '@playwright/test';
export const getDefaultAPIContext = async () => {
return await request.newContext({
baseURL: process.env.BASE_URL
});
};
Теперь напишем контекст, который будет использоваться для выполнения запросов к API с аутентификацией. В этом API https://api.sampleapis.com/futurama/questions нет аутентификации, я указал заголовок для аутентификации по API Key ради примера. Скорее всего на вашем проекте у вас будет другой заголовок для аутентификации.
core\context\auth-context.ts
import { APIRequestContext, request } from '@playwright/test';
import { APIAuth } from '../../utils/types/api/authentication';
import { getAuthAPIClient } from '../api/authentication-api';
export const getAuthAPIContext = async ({ user, authToken }: APIAuth): Promise<APIRequestContext> => {
let extraHTTPHeaders: { [key: string]: string } = {
accept: '*/*',
'Content-Type': 'application/json'
};
API endpoints
if (!user && !authToken) {
throw Error('Provide "user" or "authToken"');
}
if (user && !authToken) {
const authClient = await getAuthAPIClient();
const token = await authClient.getAuthToken(user);
extraHTTPHeaders = { ...extraHTTPHeaders, Authorization: `Token ${token}` };
}
if (authToken && !user) {
extraHTTPHeaders = { ...extraHTTPHeaders, Authorization: `Token ${authToken}` };
}
return await request.newContext({
baseURL: process.env.BASE_URL,
extraHTTPHeaders
});
};
API Clients
Теперь опишем клиенты для взаимодействия с API.
Для примера опишем методы, которые будут работать с аутентификацией. Для https://api.sampleapis.com/futurama/questions аутентификация не требуется, но в своем проекте вы можете указать ваши методы для получения токена.
core\api\authentication-api.ts
import test, { APIRequestContext, APIResponse } from '@playwright/test';
import { APIRoutes } from '../../utils/constants/routes';
import { APIClient } from '../../utils/types/api/client';
import { AuthUser } from '../../utils/types/api/authentication';
import { getDefaultAPIContext } from '../context/default-context';
class AuthAPIClient implements APIClient {
constructor(public context: APIRequestContext) {}
async getAuthTokenApi(data: AuthUser): Promise<APIResponse> {
const stepName = `Getting token for user with email "${data.email}" and password "${data.password}"`;
return await test.step(stepName, async () => {
return await this.context.post(APIRoutes.Auth, { data });
});
}
async getAuthToken(data: AuthUser): Promise<string> {
// Should be used like this:
// const response = await this.getAuthTokenApi(data);
// const json = await response.json();
// expect(response.status()).toBe(200);
// return json.token;
return 'token';
}
}
export const getAuthAPIClient = async (): Promise<AuthAPIClient> => {
const defaultContext = await getDefaultAPIContext();
return new AuthAPIClient(defaultContext);
};
Обратите внимание, что мы имплементируем APIClient
и прописываем context
в конструкторе. Далее будем передавать нужный нам контекст внутрь клиента и c помощью клиента будем выполнять запросы.
Клиент для questions:
import test, { APIRequestContext, APIResponse } from '@playwright/test';
import { expectStatusCode } from '../../utils/assertions/solutions';
import { APIRoutes } from '../../utils/constants/routes';
import { APIClient } from '../../utils/types/api/client';
import { Question, UpdateQuestion } from '../../utils/types/api/questions';
export class QuestionsAPIClient implements APIClient {
constructor(public context: APIRequestContext) {}
async getQuestionAPI(questionId: number): Promise<APIResponse> {
return await test.step(`Getting question with id "${questionId}"`, async () => {
return await this.context.get(`${APIRoutes.Questions}/${questionId}`);
});
}
async getQuestionsAPI(): Promise<APIResponse> {
return await test.step('Getting questions', async () => {
return await this.context.get(APIRoutes.Questions);
});
}
async createQuestionAPI(data: Question): Promise<APIResponse> {
return await test.step(`Creating question with id "${data.id}"`, async () => {
return await this.context.post(APIRoutes.Questions, { data });
});
}
async updateQuestionAPI(questionId: number, data: UpdateQuestion): Promise<APIResponse> {
return await test.step(`Updating question with id "${questionId}"`, async () => {
return await this.context.patch(`${APIRoutes.Questions}/${questionId}`, { data });
});
}
async deleteQuestionAPI(questionId: number): Promise<APIResponse> {
return await test.step(`Deleting question with id "${questionId}"`, async () => {
return await this.context.delete(`${APIRoutes.Questions}/${questionId}`);
});
}
async createQuestion(data: Question): Promise<Question> {
const response = await this.createQuestionAPI(data);
await expectStatusCode({ actual: response.status(), expected: 201, api: response.url() });
return await response.json();
}
}
Используя QuestionsAPIClient
сможем выполнять простые CRUD запросы к API https://api.sampleapis.com/futurama/questions.
Utils
Добавим необходимые утилиты, которые помогут сделать тесты лучше.
Хранить роутинги будем enum, чтобы не дублировать код и наглядно видеть, какие роутинги используются:
utils\constants\routes.ts
export enum APIRoutes {
Auth = '/auth',
Info = '/futurama/info',
Cast = '/futurama/cast',
Episodes = '/futurama/episodes',
Questions = '/futurama/questions',
Inventory = '/futurama/inventory',
Characters = '/futurama/characters'
}
Добавим утилиты для рандомной генерации данных:
utils\fakers.ts
const NUMBERS = '0123456789';
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const LETTERS_WITH_NUMBERS = LETTERS + NUMBERS;
export const randomNumber = (start: number = 500, end: number = 2000): number =>
Math.floor(Math.random() * (end - start + 1) + end);
export const randomString = (start: number = 10, end: number = 20, charSet: string = LETTERS_WITH_NUMBERS): string => {
let randomString = '';
for (let index = 0; index < randomNumber(start, end); index++) {
const randomPoz = Math.floor(Math.random() * charSet.length);
randomString += charSet.substring(randomPoz, randomPoz + 1);
}
return randomString;
};
export const randomListOfStrings = (start: number = 10, end: number = 20): string[] => {
const range = randomNumber(start, end);
return Array.from(Array(range).keys()).map((_) => randomString());
};
Я использовал нативные средства, чтобы сгенерировать рандомную строку, число; нам этого будет более чем достаточно. В своих же проектах вы можете использовать фейкеры для генерации данных, например, faker-js.
Теперь напишем утилиты, которые помогут нам сгенерировать данные для отправки в API:
utils\api\questions.ts
import { randomListOfStrings, randomNumber, randomString } from '../fakers';
import { Question, UpdateQuestion } from '../types/api/questions';
export const getRandomUpdateQuestion = (): UpdateQuestion => ({
question: randomString(),
correctAnswer: randomString(),
possibleAnswers: randomListOfStrings()
});
export const getRandomQuestion = (): Question => ({
id: randomNumber(),
question: randomString(),
correctAnswer: randomString(),
possibleAnswers: randomListOfStrings()
});
utils\fixtures.ts
import { Fixtures } from '@playwright/test';
export const combineFixtures = (...args: Fixtures[]): Fixtures =>
args.reduce((acc, fixture) => ({ ...acc, ...fixture }), {});
В данном примере combineFixtures
- это вспомогательный метод, который поможет нам собрать несколько объектов фикстур в один. Можно обойтись и без него, но мне так комфортнее.
Assertions
Перед тем, как начнем писать тесты, необходимо подготовить проверки.
Опишем базовые проверки, которые будут использоваться во всем проекте:
utils\assertions\solutions.ts
import { expect, test } from '@playwright/test';
type ExpectToEqual<T> = {
actual: T;
expected: T;
description: string;
};
type ExpectStatusCode = { api: string } & Omit<ExpectToEqual<number>, 'description'>;
export const expectToEqual = async <T>({ actual, expected, description }: ExpectToEqual<T>) => {
await test.step(`Checking that "${description}" is equal to "${expected}"`, async () => {
expect(actual).toEqual(expected);
});
};
export const expectStatusCode = async ({ actual, expected, api }: ExpectStatusCode): Promise<void> => {
await test.step(`Checking that response status code for API "${api}" equal to ${expected}`, async () => {
await expectToEqual({ actual, expected, description: 'Response Status code' });
});
};
По сути вы можете не писать эти обертки, но тогда в отчете вместо читабельного шага будет отображаться что-то по типу expect.toEqual
, что неинформативно. Поэтому лучше все же воспользоваться решением выше.
Добавим проверки для questions:
utils\assertions\api\questions.ts
import { Question, UpdateQuestion } from '../../types/api/questions';
import { expectToEqual } from '../solutions';
type AssertQuestionProps = {
expectedQuestion: Question;
actualQuestion: Question;
};
type AssertUpdateQuestionProps = {
expectedQuestion: UpdateQuestion;
actualQuestion: UpdateQuestion;
};
export const assertUpdateQuestion = async ({ expectedQuestion, actualQuestion }: AssertUpdateQuestionProps) => {
await expectToEqual({
actual: expectedQuestion.question,
expected: actualQuestion.question,
description: 'Question "question"'
});
await expectToEqual({
actual: expectedQuestion.correctAnswer,
expected: actualQuestion.correctAnswer,
description: 'Question "correctAnswer"'
});
await expectToEqual({
actual: expectedQuestion.possibleAnswers,
expected: actualQuestion.possibleAnswers,
description: 'Question "possibleAnswers"'
});
};
export const assertQuestion = async ({ expectedQuestion, actualQuestion }: AssertQuestionProps) => {
await expectToEqual({ actual: expectedQuestion.id, expected: actualQuestion.id, description: 'Question "id"' });
await assertUpdateQuestion({ expectedQuestion, actualQuestion });
};
Мы написали функции assertUpdateQuestion
, assertQuestion
, чтобы потом использовать и переиспользовать их в тестах. Если отказаться от этого слоя, то мы получим кучу дубликатов в тестах.
Schema
Нам нужно описать модуль валидации JSON схемы. Для валидации будем использовать библиотеку https://ajv.js.org/guide/typescript.html
Напишем валидатор:
utils\schema\validator.ts
import test from '@playwright/test';
import Ajv, { JSONSchemaType } from 'ajv';
const ajv = new Ajv();
type ValidateSchemaProps<T> = {
schema: JSONSchemaType<T>;
json: T | T[];
};
export const validateSchema = async <T>({ schema, json }: ValidateSchemaProps<T>) => {
await test.step('Validating json schema', async () => {
const validate = ajv.compile(schema);
if (!validate(json)) {
const prettyJson = JSON.stringify(json, null, 2);
const prettyError = JSON.stringify(validate.errors, null, 2);
throw Error(`Schema validation error: ${prettyError}\nJSON: ${prettyJson}`);
}
});
};
Функция validateSchema
будет принимать схему и объект JSON, который должен быть провалидирован.
Далее нужно описать схему для questions:
utils\schema\api\questions-schema.ts
import { JSONSchemaType } from 'ajv';
import { Question, UpdateQuestion } from '../../types/api/questions';
export const questionSchema: JSONSchemaType<Question> = {
title: 'Question',
type: 'object',
properties: {
id: { type: 'integer' },
question: { type: 'string' },
possibleAnswers: { type: 'array', items: { type: 'string' } },
correctAnswer: { anyOf: [{ type: 'string' }, { type: 'integer' }] }
},
required: ['id', 'question', 'correctAnswer', 'possibleAnswers']
};
export const updateQuestionSchema: JSONSchemaType<UpdateQuestion> = {
title: 'UpdateQuestion',
type: 'object',
properties: {
question: { type: 'string', nullable: true },
possibleAnswers: { type: 'array', items: { type: 'string' }, nullable: true },
correctAnswer: { type: 'string', nullable: true }
}
};
export const questionsListSchema: JSONSchemaType<Question[]> = {
title: 'QuestionsList',
type: 'array',
items: {
$ref: '#/definitions/question',
type: 'object',
required: ['id', 'question', 'correctAnswer', 'possibleAnswers']
},
definitions: {
question: {
title: 'Question',
type: 'object',
properties: {
id: { type: 'integer' },
question: { type: 'string' },
possibleAnswers: { type: 'array', items: { type: 'string' } },
correctAnswer: { anyOf: [{ type: 'string' }, { type: 'integer' }] }
},
required: ['id', 'question', 'correctAnswer', 'possibleAnswers']
}
}
};
О том, как писать JSON схему можно посмотреть тут https://json-schema.org/understanding-json-schema/. А сгенерировать JSON схему можно, например, тут https://www.liquid-technologies.com/online-json-to-schema-converter.
Fixtures
И последнее, что нам нужно сделать перед написанием тестов, - это описать фикстуры.
Сперва напишем фикстуру для получения тестового пользователя:
fixtures\users.ts
import { Fixtures } from '@playwright/test';
import { AuthUser } from '../utils/types/api/authentication';
export type UsersFixture = {
testUser: AuthUser;
};
export const usersFixture: Fixtures<UsersFixture> = {
testUser: async ({}, use) => {
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) {
throw Error(`Provide "TEST_USER_EMAIL" and "TEST_USER_PASSWORD" inside .env`);
}
await use({ email, password });
}
};
Теперь напишем фикстуры для questions:
fixtures\questions.ts
import { Fixtures } from '@playwright/test';
import { QuestionsAPIClient } from '../core/api/questions-api';
import { getAuthAPIContext } from '../core/context/auth-context';
import { getRandomQuestion } from '../utils/api/questions';
import { Question } from '../utils/types/api/questions';
import { UsersFixture } from './users';
export type QuestionsFixture = {
questionsClient: QuestionsAPIClient;
question: Question;
};
export const questionsFixture: Fixtures<QuestionsFixture, UsersFixture> = {
questionsClient: async ({ testUser }, use) => {
const authContext = await getAuthAPIContext({ user: testUser });
const questionsClient = new QuestionsAPIClient(authContext);
await use(questionsClient);
},
question: async ({ questionsClient }, use) => {
const randomQuestion = getRandomQuestion();
const question = await questionsClient.createQuestion(randomQuestion);
await use(question);
await questionsClient.deleteQuestionAPI(question.id);
}
};
Фикстура questionsClient
будет конструировать и передавать нам в тесты клиент для взаимодействия с questions API. Фикстура question
будет создавать объект question через API и по окончанию теста удалит созданный объект.
Testing
Теперь можно писать тесты, используя все клиенты, функции, фикстуры, проверки, которые были написаны выше.
Сделаем extend стандартного test объекта из playwright и добавим в него свои фикстуры:
tests\questions-test.ts
import { test as base } from '@playwright/test';
import { questionsFixture, QuestionsFixture } from '../fixtures/questions';
import { usersFixture, UsersFixture } from '../fixtures/users';
import { combineFixtures } from '../utils/fixtures';
export const questionsTest = base.extend<UsersFixture, QuestionsFixture>(
combineFixtures(usersFixture, questionsFixture)
);
tests\questions.spec.ts
import { getRandomQuestion, getRandomUpdateQuestion } from '../utils/api/questions';
import { assertQuestion, assertUpdateQuestion } from '../utils/assertions/api/questions';
import { expectStatusCode } from '../utils/assertions/solutions';
import { questionSchema, questionsListSchema, updateQuestionSchema } from '../utils/schema/api/questions-schema';
import { validateSchema } from '../utils/schema/validator';
import { Question } from '../utils/types/api/questions';
import { questionsTest as test } from './questions-test';
test.describe('Questions', () => {
test('Get question', async ({ question, questionsClient }) => {
const response = await questionsClient.getQuestionAPI(question.id);
const json: Question = await response.json();
await expectStatusCode({ actual: response.status(), expected: 200, api: response.url() });
await assertQuestion({ expectedQuestion: question, actualQuestion: json });
await validateSchema({ schema: questionSchema, json });
});
test('Get questions', async ({ questionsClient }) => {
const response = await questionsClient.getQuestionsAPI();
const json: Question[] = await response.json();
await expectStatusCode({ actual: response.status(), expected: 200, api: response.url() });
await validateSchema({ schema: questionsListSchema, json });
});
test('Create question', async ({ questionsClient }) => {
const payload = getRandomQuestion();
const response = await questionsClient.createQuestionAPI(payload);
const json: Question = await response.json();
await expectStatusCode({ actual: response.status(), expected: 201, api: response.url() });
await assertQuestion({ expectedQuestion: payload, actualQuestion: json });
await validateSchema({ schema: questionSchema, json });
});
test('Update question', async ({ question, questionsClient }) => {
const payload = getRandomUpdateQuestion();
const response = await questionsClient.updateQuestionAPI(question.id, payload);
const json: Question = await response.json();
await expectStatusCode({ actual: response.status(), expected: 200, api: response.url() });
await assertUpdateQuestion({ expectedQuestion: payload, actualQuestion: json });
await validateSchema({ schema: updateQuestionSchema, json });
});
test('Delete question', async ({ question, questionsClient }) => {
const deleteQuestionResponse = await questionsClient.deleteQuestionAPI(question.id);
const getQuestionResponse = await questionsClient.getQuestionAPI(question.id);
await expectStatusCode({
actual: getQuestionResponse.status(),
expected: 404,
api: getQuestionResponse.url()
});
await expectStatusCode({
actual: deleteQuestionResponse.status(),
expected: 200,
api: deleteQuestionResponse.url()
});
});
});
Тут пять тестов на стандартные CRUD операции для questions API https://api.sampleapis.com/futurama/questions.
Возвращаясь к нашим требованиям:
Проверяем статус код ответа, тело ответа, JSON схему;
Данные готовятся внутри фикстур;
На отчет посмотрим ниже.
Report
Перед генерацией отчета хочу показать одну интересную фичу playwright, с помощью которой мы можем делать глобальные setup, teardown.
В playwright.config.ts мы добавляли такую запись:
globalTeardown: require.resolve('./utils/config/global-teardown')
Которая указывает на путь к файлу, из которого экспортирована функция globalTeardown
. Playwright запустит эту функцию по окончанию тестовой сессии. Для примера давайте сделаем отображение всех переменных окружения в allure отчете с помощью global-teardown:
utils\reporters\allure.ts
import fs from 'fs';
import path from 'path';
export const createAllureEnvironmentFile = (): void => {
const reportFolder = path.resolve(process.cwd(), process.env.ALLURE_RESULTS_FOLDER);
const environmentContent = Object.entries(process.env).reduce(
(previousValue, [variableName, value]) => `${previousValue}\n${variableName}=${value}`,
''
);
fs.mkdirSync(reportFolder, { recursive: true });
fs.writeFileSync(`${reportFolder}/environment.properties`, environmentContent, 'utf-8');
};
utils\config\global-teardown.ts
import { FullConfig } from '@playwright/test';
import { createAllureEnvironmentFile } from '../reporters/allure';
async function globalTeardown(_: FullConfig): Promise<void> {
createAllureEnvironmentFile();
}
export default globalTeardown;
По аналогии вы можете сделать, например, получение токена в начале тестовой сессии globalSetup, а потом очистку базы данных после окончания тестовой сессии globalTeardown.
В отчете увидим переменные окружения, которые мы прописывали в globalTeardown
Запустим тесты и посмотрим на отчет:
npx playwright test
Теперь запустим отчет:
allure serve
Либо можете собрать отчет и в папке allure-reports открыть файл index.html:
allure generate
Полную версию отчета посмотрите тут.
Заключение
Весь исходный код проекта расположен на моем github.