Гибкий и эффективный подход к паттерну POM
(Page Object Model)
Андрей Михайлов
команда Monium UI
Яндекс
export class ElementPO extends PageObject {
/*--- Actions ---*/
click(options?: ClickOptions): Promise<void> {
return this.element.click(options);
}
hover(options?: HoverOptions): Promise<void> {
return this.element.hover(options);
}
waitFor(options?: WaitForOptions): Promise<void> {
return this.element.waitFor(options);
}
// ....
/*--- Filters ---*/
filterByText(hasText: string): this {
return this.derive((locator) => locator.filter({hasText}));
}
// ...
/*--- Assertions ---*/
shouldHaveText(text: string, options?: ToHaveTextOptions): Promise<void> {
return expect(this.element).toHaveText(text, options);
}
// ...
}
class MyPage extends BasePage {
Title = new ElementPO(this, '.Title');
Field = new InputPO(this, '.Field');
SubmitButton = new ElementPO(this, '.Button');
visit () {
// Base URL is baked in
return super.visit('/my-page');
}
}
Базовый класс
Ваш page object
await Page.Title.click();
await Page.Bar.click({timeout: 300);
await Page.Baz.click({force: true);
await Page.Foo.waitFor();
await Page.Bar.waitFor('attached');
await Page.Baz.waitFor('hidden');
await Page.Foo.screenshot('screenshot-name-1');
await Page.Bar.screenshot('screenshot-name-2', {mask: ['#page-footer-wrapper']});
await Page.Baz.screenshot('screenshot-name-3');
Тест
import {ElementPO as ElementPOBase} from '@pomeranian/lib-standard';
export class ElementPO extends ElementPOBase {
/*--- Actions ---*/
async screenshot(screenshotName: string, options: LocatorScreenshotOptions = {}) {
const optionsMerged: LocatorScreenshotOptions = {
animations: 'disabled',
...options,
};
const testInfo = this.testInfo;
const screenshot = await this.element.screenshot(optionsMerged);
const testName = getTestName(testInfo);
const projectName = testInfo.project.name;
const sanitizedName = sanitize(screenshotName);
const screenshotPath = `${projectName}-${testName}/${sanitizedName}.png`;
await expect(screenshot).toMatchSnapshot(screenshotPath);
}
}
```
ElementPO:
* `isInViewport()`
* `hasClass(name: string)`
* `hasStyle(name: string, value?: string)`
InputPO, TextareaPO:
* `setCaretToPosition(pos: number)`
* `selectText(from: number, to: number)`
* `copySelectedTextToClipboard()`
* `append(text: string)`
ImagePO:
* `getAlt()`
* `getSrc()`
* `waitForLoad()`
const cell =
"[ConnectFailed] Failed to connect to 2a02:6b8:c12:3611:0:4a73:ba36:0 port 3443 after 8 ms: Couldn't connect to server";
await PO.ShardTargetsPage.visit({shard: 'dataui_test_kloof-core_dataui_test_push'});
await PO.ShardTargetsPage.revealTooltipOnCellWithText(cell);
await PO.ShardTargetsPage.screenshot('target-error-hover.png');
export class ShardTargetsPagePO extends EntitesListPO {
target = '[data-qa="shard-targets"]';
/*--- Elements --- */
TooltipTextWithCopy = new ElementPO(this, this.page.locator('.expandable-hover-text__tooltip-container'));
/*--- Actions ---*/
async visit({shard}: {shard: string}): Promise<void> {
const {scopeId} = this.fixtures;
const url = `/projects/${scopeId}/shards/${shard}/view/targets`;
await super.visit(url);
await this.ContentWrapper.waitFor();
}
async revealTooltipOnCellWithText(text: string): Promise<void> {
await this.Cell.filterByText(text).hover();
await this.TooltipTextWithCopy.waitFor();
}
}
export class PO extends PageObject {
/*--- Pages ---*/
ShardTargetsPage = new ShardTargetsPagePO(this);
}
Корневой page object
Page object страницы
Тест
export class EntitiesListPO extendsElementPO {
target = '.ycm-entities-list"';
/*--- Elements --- */
ContentWrapper = new ElementPO(this, '.ycm-entities-list__content-wrapper');
Cell = new ElementPO(this, (parent) => parent.getByRole('cell'));
}
Page object компонента
export class PO extends PageObject {
/*--- Pages ---*/
MyPage = new MyPagePO(this);
/*--- Modals ---*/
ConfirmationDialog = new ConfirmationDialogPO(this);
}
Корневой page object
export class MyPagePO extends ElementPO {
/* --- Actions --- */
async restart() {
await this.RestartButton.click();
await this.root.ConfirmationModal.OkButton.click();
}
}
Page object страницы
await PO.MyPage.restart();
Тест
import test from '@/tests';
test('Should restart', ({PO}) => {
await PO.MyPage.visit()
await PO.MyPage.restart();
await PO.Toast.shouldHaveText('OK');
});
Ноу-хау от Ильи Исупова (@matumbaman, @SwinX)
import test from '@/tests';
import AvatarPO from "@pomeranian/gravity-ui-kit";
test('Should see avatar', async ({page, BASE_URL}) => {
const avatar = new AvatarPO({page}); // 👈
await page.goto(`${BASE_URL}/service-providers/idm'`);
await avatar.shouldBeVisible();
});
Но можно и вручную:
import test from '@/tests';
test('Should restart', ({PO}) => {
await PO.MyPage.visit()
await PO.MyPage.restart();
await PO.Toast.shouldHaveText('OK');
});
import {expect} from 'playwright/test';
import {PageObject, type ToHaveTextOptions} from '@pomeranian/core';
export class ElementPO extends PageObject {
/*--- Assertions ---*/
shouldHaveText(text: string, options?: ToHaveTextOptions): Promise<void> {
return expect(this.element).toHaveText(text, options);
}
}
Element.shouldBeFocused()
Element.shouldBeOnlyChild()
Element.shouldBeNthChild(2)
Input.shouldHaveSelectedText('foo')
Input.shouldHaveSelectedRange(2, 7)
Input.shouldHaveValidationMessage('Too short')
Link.shouldHaveAnchorTo(Header)
CustomTable
.shouldBeSortedAlphabeticallyByColumn('id')
Недостаток: отсутствие инвертирования условия .not()
DashboardItem = new DasbhoardItemPO(this);
await PO.MyPage.DashboardItem.Title.click() // ❌ error
await PO.MyPage.DashboardItem.first().Title.click();
await PO.MyPage.DashboardItem.nth(2).Title.click();
await PO.MyPage.DashboardItem.withText('Foo').Title.click();
await PO.MyPage.DashboardItem.withAttr('title', 'Foo').Title.click();
MyPagePO:
Тест
Ноу-хау от Макария Шаоряна
(@makar-s)
import {AvatarPO} from '@pomeranian/gravity-ui-kit';
Ноу-хау от Макария Шаоряна
(@makar-s)
AnyAvatar = new AvatarPO(this);
Будет использовать встроенный селектор `.g-avatar`:
CreatorAvatar = new AvatarPO(this, '[data-qa="creator"]');
ModifierAvatar = new AvatarPO(this, '[data-qa="modifier"]'));
Будут использовать кастомные селекторы:
Ноу-хау от Макария Шаоряна
(@makar-s)
export class AvatarPO extends PageObject {
target = '.g-avatar';
}
Встроенный селектор объявляется так:
export class MyPagePO extends ElementPO {
target = '.my-page';
Foo = new ElementPO(this, '.foo');
} // ☝️
PO.MyPage.Foo.locator.toString()
export class MyPagePO extends ElementPO {
target = '.my-page';
Bar = new ElementPO(this, (parentLoc) => parentLoc.getByRole('button'));
} // ☝️
PO.MyPage.Baz.locator.toString()
export class MyPagePO extends ElementPO {
target = '.my-page';
Bar = new ElementPO(this, this.page.getByRole('button'));
} // ☝️
PO.MyPage.Bar.locator.toString()
↳ locator('.my-page').locator('.foo')
↳ getByRole('button')
↳ locator('.my-page').getByRole('button')
Строка:
скоупится
Локатор:
не скоупится
Колбэк:
вручную
Ноу-хау Андрея Волынкина
(@avol-v)