Вступление
В данной статье мы разберем, как писать UI автотесты с использованием паттернов Page Object, Page Factory на языке TypeScript. У меня уже была статья о том Как правильно писать UI авто тесты на Python, тут мы разберем аналогичный пример.
Requirements
Для примера написания UI авто тестов мы будем использовать:
playwright - yarn add playwright/npm install playwright
allure - yarn add allure-playwright/npm install allure-playwright
Вы можете использовать любой другой фреймворк и репортер, суть концепции работы с Page Object, Page Factory будет похожей на каждом фреймворке.
Обратите внимание, что все автотесты будут писаться через async, await, т.к. это единственный возможный способ для playwright. В других фреймворках может быть иначе.
Авто тесты будем писать на эту страницу https://playwright.dev/
Тест кейс:
Открываем страницу https://playwright.dev
Нажимаем на поиск
Проверяем, что модальное окно поиска успешно открылось
Вводим в поиск язык, в нашем случае будет python
Выбираем из результатов первый
Проверяем, что страница с Python открылась
Отмечу, что локаторы в примерах ниже не являются эталонными, а сайт для тестирования - это документация playwright, на фронтенд которой я никак не могу повлиять. В ваших проектах советую использовать кастомные data-qa-id, которые вы можете поставить в фронтенд приложении React/Vue/Angular или же попросить разработчиков сделать это.
Файл конфигурации playwright будет выглядеть стандартным образом, добавим лишь allure-report, headles, video, screenshot. Более подробно про конфигурацию playwright можно почитать тут.
Playwright позволяет из коробки записывать видео и делать скриншоты + крепить их к отчету, для этого достаточно прописать video: "on"
, screenshot: "on"
, там есть и другие параметры. Если вам необходимо сохранять видео только на фейленный тест, то используйте video: "retain-on-failure
, аналогично со скриншотом screenshot: "only-on-failure"
. Конечно же вы можете сделать тоже самое и с Selenium. В конце статьи посмотрим на видео и скриншот в отчете.
playwright.config.ts
import type { PlaywrightTestConfig } from '@playwright/test';
import { devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
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']],
/* 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,
baseURL: 'https://playwright.dev',
/* 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',
headless: !!process.env.CI,
video: 'on',
screenshot: 'on'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome']
}
}
]
};
export default config;
Base Page
По сути Base Page - это основная страница, которая не описывает какую-то конкретную страницу или компонент. Сама по себе Base Page не должна использоваться в тестах и от нее мы наследуем наши страницы или компоненты.
pages\base-page.ts
import test, { Page } from '@playwright/test';
import { Navbar } from '../components/navigation/navbar';
export class BasePage {
readonly navbar: Navbar;
constructor(public page: Page) {
this.navbar = new Navbar(page);
}
async visit(url: string): Promise<void> {
await test.step(`Opening the url "${url}"`, async () => {
await this.page.goto(url, { waitUntil: 'networkidle' });
});
}
async reload(): Promise<void> {
const currentUrl = this.page.url();
await test.step(`Reloading page with url "${currentUrl}"`, async () => {
await this.page.reload({ waitUntil: 'domcontentloaded' });
});
}
}
Внутри BasePage описываем базовые методы. Это лишь образец того, как можно делать BasePage
Page Factory
Теперь самое интересное. Мы определим несколько базовых компонентов для реализации работы паттерна. Но перед реализацией компонентов нам нужно добавить необходимые типы и вспомогательные методы.
utils\generic.ts
export const capitalizeFirstLetter = (string: string): string => string.charAt(0).toUpperCase() + string.slice(1);
utils\page-factory.ts
import { LocatorContext } from '../types/page-factory/component';
export const locatorTemplateFormat = (locator: string, { ...context }: LocatorContext): string => {
let template = locator;
for (const [key, value] of Object.entries(context)) {
template = template.replace(`{${key}}`, value.toString());
}
return template;
};
По сути функция locatorTemplateFormat
будет брать строку локатора, например, [data-qa-id="here-my-locator-with-index-{index}"]
и подставлять параметры и context
внутрь строки локатора. Например:
const locator = '[data-qa-id="here-my-locator-with-index-{index}"]';
const context = {index: 5};
locatorTemplateFormat(locator, context) => '[data-qa-id="here-my-locator-with-index-5"]
Реализация locatorTemplateFormat
может быть любая, я лишь привел пример, как это можно сделать. В своем фреймворке вы можете использовать другой форматер для шаблона локатора.
types\page-factory\component.ts
import { Page } from '@playwright/test';
export type LocatorContext = { [key: string]: string | boolean | number };
export type ComponentProps = {
page: Page;
name?: string;
locator: string;
};
export type LocatorProps = { locator?: string } & LocatorContext;
Базовый Component. Сам по себе Component не должен использоваться в тестах и от него мы будем наследовать другие компоненты и при необходимости переопределять методы, поэтому сделаем его абстрактным классом.
page-factory\component.ts
import { expect, Locator, Page, test } from '@playwright/test';
import { ComponentProps, LocatorProps } from '../types/page-factory/component';
import { capitalizeFirstLetter } from '../utils/generic';
import { locatorTemplateFormat } from '../utils/page-factory';
export abstract class Component {
page: Page;
locator: string;
private name: string | undefined;
constructor({ page, locator, name }: ComponentProps) {
this.page = page;
this.locator = locator;
this.name = name;
}
getLocator(props: LocatorProps = {}): Locator {
const { locator, ...context } = props;
const withTemplate = locatorTemplateFormat(locator || this.locator, context);
return this.page.locator(withTemplate);
}
get typeOf(): string {
return 'component';
}
get typeOfUpper(): string {
return capitalizeFirstLetter(this.typeOf);
}
get componentName(): string {
if (!this.name) {
throw Error('Provide "name" property to use "componentName"');
}
return this.name;
}
private getErrorMessage(action: string): string {
return `The ${this.typeOf} with name "${this.componentName}" and locator ${this.locator} ${action}`;
}
async shouldBeVisible(locatorProps: LocatorProps = {}): Promise<void> {
await test.step(`${this.typeOfUpper} "${this.componentName}" should be visible on the page`, async () => {
const locator = this.getLocator(locatorProps);
await expect(locator, { message: this.getErrorMessage('is not visible') }).toBeVisible();
});
}
async shouldHaveText(text: string, locatorProps: LocatorProps = {}): Promise<void> {
await test.step(`${this.typeOfUpper} "${this.componentName}" should have text "${text}"`, async () => {
const locator = this.getLocator(locatorProps);
await expect(locator, { message: this.getErrorMessage(`does not have text "${text}"`) }).toContainText(text);
});
}
async click(locatorProps: LocatorProps = {}): Promise<void> {
await test.step(`Clicking the ${this.typeOf} with name "${this.componentName}"`, async () => {
const locator = this.getLocator(locatorProps);
await locator.click();
});
}
}
Выше приведена очень упрощенная реализация Component. В своем проекте вы можете добавить больше методов, больше настроек к ним, можете изменить названия шагов test.step, которые больше подходят вам.
Давайте сделаем еще несколько компонентов.
Button - кнопка. В данном компоненте будут базовые методы для работы с кнопками.
page-factory\button.ts
import test from '@playwright/test';
import { LocatorProps } from '../types/page-factory/component';
import { Component } from './component';
export class Button extends Component {
get typeOf(): string {
return 'button';
}
async hover(locatorProps: LocatorProps = {}): Promise<void> {
await test.step(`Hovering the ${this.typeOf} with name "${this.componentName}"`, async () => {
const locator = this.getLocator(locatorProps);
await locator.hover();
});
}
async doubleClick(locatorProps: LocatorProps = {}) {
await test.step(`Double clicking ${this.typeOf} with name "${this.componentName}"`, async () => {
const locator = this.getLocator(locatorProps);
await locator.dblclick();
});
}
}
Input - поле ввода. В данном компоненте будут базовые методы для работы с инпутами.
page-factory\input.ts
import test, { expect } from '@playwright/test';
import { LocatorProps } from '../types/page-factory/component';
import { Component } from './component';
type FillProps = { validateValue?: boolean } & LocatorProps;
export class Input extends Component {
get typeOf(): string {
return 'input';
}
async fill(value: string, fillProps: FillProps = {}) {
const { validateValue, ...locatorProps } = fillProps;
await test.step(`Fill ${this.typeOf} "${this.componentName}" to value "${value}"`, async () => {
const locator = this.getLocator(locatorProps);
await locator.fill(value);
if (validateValue) {
await this.shouldHaveValue(value, locatorProps);
}
});
}
async shouldHaveValue(value: string, locatorProps: LocatorProps = {}) {
await test.step(`Checking that ${this.typeOf} "${this.componentName}" has a value "${value}"`, async () => {
const locator = this.getLocator(locatorProps);
await expect(locator).toHaveValue(value);
});
}
}
Link - ссылка. В данном компоненте будут базовые методы для работы со ссылками.
page-factory\link.ts
import { Component } from './component';
export class Link extends Component {
get typeOf(): string {
return 'link';
}
}
ListItem - любой элемент списка.
page-factory\list-item.ts
import { Component } from './component';
export class ListItem extends Component {
get typeOf(): string {
return 'list item';
}
}
Title - заголовок. Можно использовать просто Text, но я предпочитаю разделать все для понятности. Title, Text, Label, Subtitle...
page-factory\title.ts
import { Component } from './component';
export class Title extends Component {
get typeOf(): string {
return 'title';
}
}
Лайфхак. Если не знаете как называть компоненты, то можете использовать библиотеки для дизайна. К примеру дизайнеры вашего продукта используют Material Design для компонентов, тогда вы можете взять посмотреть https://m3.material.io/components, как называются те или иные компоненты в Material Desgin и по аналогии называть свои компоненты Page Factory. Но если вам и так понятно, как и почему называть компоненты, то этот лайфхак не для вас :)
Теперь вопрос: "Зачем все это?". Данный подход решает сразу тонну проблем и вопросов, которые возникают у любого QA Automation, который хоть раз писал UI авто тесты.
Дает удобный и понятный интерфейс для работы с объектами на странице. То есть мы работаем не с каким-то там локатором, а с конкретным объектом, например, Button.
Универсализирует все взаимодействия и проверки компонентов. Очень хорошо для команд, где над авто тестами работают два и более QA Automation, ну или если авто тесты пишут разработчики тоже. С таким подходом у вас не будет споров и проблем, то есть один QA Automation может писать так
expect(locator).toBeVisible()
, что является единственно правильным с точки зрения playwright. Второй QA Automation может писать такexpect(await locator.isVisible()).toBeTruthy()
, что тоже по сути правильно, но костыльно. На этой основе могут возникать бесполезные споры или еще хуже: каждый пишет так, как он хочет. По итогу получаем проект, в котором одни и те же проверки пишутся по разному. С данным подходом мы один раз устанавливаем, как делается проверка и забываем об этом, все работает прекрасно.Дает возможность универсализировать все шаги для отчета. В примере выше я использовал test.step из playwright, но на самом деле это не важно, вы можете использовать любой репортер. При объявлении нового компонента нам не нужно переписывать все шаги, они динамически формируются на основе параметров, name, typeOf. Конечно же, вы можете их изменить и расширить под ваши требования. Достаточно переопределить typeOf и мы получаем новый компонент с полностью уникальными шагами.
Динамические локаторы - это вечная боль, но не с данным подходом. Механизм форматирования локатора до безобразия прост. Мы передаем
LocatorProps
в каждый метод, далее все это идет в сам локатор через функцию форматированияlocatorTemplateFormat
. То есть это позволяет писать нам локаторыspan#some-element-id-{userId}
, далее передаватьuserId
черезLocatorProps
прямо в локатор, например такthis.myUserInput.fill('Hello World', { userId })
Механизм прост, но он избавляет нас от локаторов в методах, от дублирования или еще хуже хардкода локаторов.Появляется возможность создавать компоненты, работа с которыми специфична. Например, у вас в продукте есть какой-то хитрый автокомплит, который нужно как-то хитро кликать, возможно, печатать через клавиатуру. Вы можете создать компонент
MyCustomAutocomplete
, унаследовать его отInput
и переопределить методfill
. Далее такой компонент можно будет использовать во всем проекте без дублирования логики ввода.Если у вас над автотестами работают сразу несколько QA Automation команд, например, одна тестирует админку, другая тестирует сайт, то вы можете вынести весь Page Factory внутрь библиотеки. Библиотеку можно запушить на pypi или на свой приватный nexus сервер. Далее библиотекой могут пользоваться все QA Automation команды, вы получите общий ентри поинт для написания UI авто тестов, общие шаги и проверки.
Last but not least. Предложенный мною подход Page Factory максимально простой, а это очень важно для масштабирования в будущем. Хуже, когда в коде наблюдается "магия" и эта "магия" понятна только тому, кто ее создал. В решение выше та "магия" отсутствует, все максимально прозрачно и в рамках обычного ООП.
Возможно, данный подход не является классической реализацией Page Factory, но это единственное рабочее и адекватное решение, которое мне удалось выработать. Предложенное мною решение способно закрыть все вопросы и проблемы работы с компонентами.
Для меня главным образом закрывается две задачи:
Фокус на тестировании бизнес логики, без вечных головоломок с кодом;
Красивый и понятный отчет, который спокойно могут читать Manual QA, менеджеры и разработчики.
Pages
Теперь опишем страницы, которые нам понадобятся, уже с использованием Page Factory.
Основная страница playwright https://playwright.dev
pages\playwright-home-page.ts
import { BasePage } from './base-page';
export class PlaywrightHomePage extends BasePage {}
Страница с языками https://playwright.dev/python/docs/languages
pages\playwright-languages-page.ts
import { Page } from '@playwright/test';
import { Title } from '../page-factory/title';
import { capitalizeFirstLetter } from '../utils/generic';
import { BasePage } from './base-page';
export class PlaywrightLanguagesPage extends BasePage {
private readonly languageTitle: Title;
constructor(public page: Page) {
super(page);
this.languageTitle = new Title({ page, locator: 'h2#{language}', name: 'Language title' });
}
async languagePresent(language: string): Promise<void> {
await this.languageTitle.shouldBeVisible({ language });
await this.languageTitle.shouldHaveText(capitalizeFirstLetter(language), { language });
}
}
Components
Теперь опишем компоненты, которые нам будут нужны.
Navbar
components\navigation\navbar.ts
import { Page } from '@playwright/test';
import { Button } from '../../page-factory/button';
import { Link } from '../../page-factory/link';
import { SearchModal } from '../modals/search-modal';
export class Navbar {
readonly searchModal: SearchModal;
private readonly apiLink: Link;
private readonly docsLink: Link;
private readonly searchButton: Button;
constructor(public page: Page) {
this.searchModal = new SearchModal(page);
this.apiLink = new Link({ page, locator: "//a[text()='API']", name: 'API' });
this.docsLink = new Link({ page, locator: "//a[text()='Docs']", name: 'Docs' });
this.searchButton = new Button({ page, locator: 'button.DocSearch-Button', name: 'Search' });
}
async visitDocs(): Promise<void> {
await this.docsLink.click();
}
async visitApi(): Promise<void> {
await this.apiLink.click();
}
async openSearch(): Promise<void> {
await this.searchButton.shouldBeVisible();
await this.searchButton.hover();
await this.searchButton.click();
await this.searchModal.modalIsOpened();
}
}
SearchModal
components\modals\search-modal.ts
import { Page } from '@playwright/test';
import { Input } from '../../page-factory/input';
import { ListItem } from '../../page-factory/list-item';
import { Title } from '../../page-factory/title';
type FindResult = {
keyword: string;
resultNumber: number;
};
export class SearchModal {
private readonly emptyResultsTitle: Title;
private readonly searchInput: Input;
private readonly searchResult: ListItem;
constructor(public page: Page) {
this.emptyResultsTitle = new Title({ page, locator: 'p.DocSearch-Help', name: 'Empty results' });
this.searchInput = new Input({ page, locator: '#docsearch-input', name: 'Search docs' });
this.searchResult = new ListItem({ page, locator: '#docsearch-item-{resultNumber}', name: 'Result item' });
}
async modalIsOpened(): Promise<void> {
await this.searchInput.shouldBeVisible();
await this.emptyResultsTitle.shouldBeVisible();
}
async findResult({ keyword, resultNumber }: FindResult) {
await this.searchInput.fill(keyword, { validateValue: true });
await this.searchResult.click({ resultNumber });
}
}
Лайфхак. Если вы путаетесь и не знаете, как назвать какой-либо компонент, то можете посмотреть, как называются компоненты у разработчиков внутри React/Vue/Angular frontend приложения. Да, не всегда есть такая возможность, но если имеется доступ к frontend приложению, то советую брать названия и структуру компонентов именно от туда. Разработчики точно лучше знают структуру frontend приложения, чем любой QA Engineer, + вам будет легче в будущем повторно использовать компоненты и композицию. В нашем примере c https://playwright.dev/ мы не имеем доступа к коду приложения, поэтому называем компоненты по смыслу.
Testing
Теперь настало время теста. Тут все просто, у нас есть готовые страницы, тест соберем, как конструктор, но перед этим напишем фикстуры.
Лайфхак. Наверняка у вас в проекте есть много ресурсов, которые не влияют на основную бизнес-логику приложения. Например такие, как шрифты (woff,woff2), картинки (png, jpg, jpeg), иконки (ico, svg), музыкальные файлы (mp3), какие-то конфиги, которые фронт подгружает с бэка, можно отключить, это позволит сильно ускорить ваши тесты, проверьте. Конечно, не все ресурсы можно отключить, к этому тоже нужно относиться очень внимательно, посмотрите, что можно отключить или посоветуйтесь с разработчиками, например, отключать main.js/index.js/app.js или чанки точно не стоит :) Мы отключим загрузку статических файлов c помощью playwright:
utils\mocks\static-mock.ts
import { Page } from '@playwright/test';
export const mockStaticRecourses = async (page: Page): Promise<void> => {
await page.route('**/*.{ico,png,jpg,mp3,woff,woff2}', (route) => route.abort());
};
fixtures\context-pages.ts
import { Fixtures, Page, PlaywrightTestArgs } from '@playwright/test';
import { mockStaticRecourses } from '../utils/mocks/static-mock';
export type ContextPagesFixture = {
contextPage: Page;
};
export const contextPagesFixture: Fixtures<ContextPagesFixture, PlaywrightTestArgs> = {
contextPage: async ({ page }, use) => {
await mockStaticRecourses(page);
await use(page);
}
};
fixtures\playwright-pages.ts
import { Fixtures } from '@playwright/test';
import { PlaywrightHomePage } from '../pages/playwright-home-page';
import { PlaywrightLanguagesPage } from '../pages/playwright-languages-page';
import { ContextPagesFixture } from './context-pages';
export type PlaywrightPagesFixture = {
playwrightHomePage: PlaywrightHomePage;
playwrightLanguagesPage: PlaywrightLanguagesPage;
};
export const playwrightPagesFixture: Fixtures<PlaywrightPagesFixture, ContextPagesFixture> = {
playwrightHomePage: async ({ contextPage }, use) => {
const playwrightHomePage = new PlaywrightHomePage(contextPage);
await use(playwrightHomePage);
},
playwrightLanguagesPage: async ({ contextPage }, use) => {
const playwrightLanguagesPage = new PlaywrightLanguagesPage(contextPage);
await use(playwrightLanguagesPage);
}
};
Теперь сделаем extend стандартного test объекта из playwright и добавим в него свои фикстуры
tests\tests.ts
import { test as base } from '@playwright/test';
import { ContextPagesFixture, contextPagesFixture } from '../fixtures/context-pages';
import { PlaywrightPagesFixture, playwrightPagesFixture } from '../fixtures/playwright-pages';
import { combineFixtures } from '../utils/fixtures';
export const searchTest = base.extend<ContextPagesFixture, PlaywrightPagesFixture>(
combineFixtures(contextPagesFixture, playwrightPagesFixture)
);
В данном примере combineFixtures
- это вспомогательный метод, который поможет нам собрать несколько объектов фикстур в один. В принципе можно обойтись и без него, но мне так удобнее:
utils\fixtures.ts
import { Fixtures } from '@playwright/test';
export const combineFixtures = (...args: Fixtures[]): Fixtures =>
args.reduce((acc, fixture) => ({ ...acc, ...fixture }), {});
tests\test-search.spec.ts
import { searchTest as test } from './tests';
test.beforeEach(async ({ playwrightHomePage }) => {
await playwrightHomePage.visit('/');
});
test('Testing search on playwright documentation page', async ({ playwrightHomePage, playwrightLanguagesPage }) => {
await playwrightHomePage.navbar.openSearch();
await playwrightHomePage.navbar.searchModal.findResult({ keyword: 'python', resultNumber: 0 });
await playwrightLanguagesPage.languagePresent('python');
});
При использовании Page Object, Page Factory, как описано выше, тесты пишутся легко и понятно. И, самое главное, это позволяет нам фокусироваться на тестировании бизнес логики продукта, а не на ерунде по типу, как написать test.step, как написать проверку или как мне динамически подставить параметр в локатор.
Report
Запустим тесты и посмотрим на отчет:
npx playwright test
Теперь запустим отчет:
allure serve
Либо можете собрать отчет и в папке allure-reports открыть файл index.html:
allure generate
Получаем прекрасный отчет, в котором отображается вся нужная нам информация.
Полную версию отчета посмотрите тут.
Заключение
Весь исходный код проекта расположен на моем github.