Вступление
В данной статье мы разберем 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.
ссылка на оригинал статьи https://habr.com/ru/post/718814/
Добавить комментарий