Pull to refresh

Пишем сложный Page object для playwright тестов вместе с Dorama

Level of difficultyMedium
Reading time6 min
Views3.3K

Всем привет! Сегодня расскажу о том, как удобно организовать Page Object для большого проекта с использованием Playwright и библиотеки Dorama (самописная).

Большинство современных веб-проектов имеют сложный интерфейс с переиспользуемыми компонентами. Причем компоненты могут переиспользоваться как на разных страницах, так и в рамках одной страницы. Поэтому важно грамотно оформить POM с самого начала, чтобы можно было добраться до любого локатора любого компонента на странице. Это упростит написание тестов и улучшит читаемость кода. При формировании страниц и компонентов мы будем использовать как наследование, так и композицию. Перейдем к делу.

Писать будем на typescript. Развернем проект на playwright с нуля, подготовим все для удобной работы и реализуем POM. Тестировать будем candlapp.com

Итак, первым делом создаем папку для проекта:

mkdir dorama-test
cd dorama-test

Выполняем:

npm init playwright@latest

Выбираем язык TypeScript и оставляем все остальное по дефолту

Устанавливаем Dorama:

npm i dorama

Давайте сразу выполним

git init

Удаляем папку test-example с примерами тестов от playwright, она нам не понадобится. Сделаем первый commit:

git add .
git commit -m "init"

Далее выполняем:

tsc -init

Эта команда создаст файл tsconfig.json, для конфигурации компилятора TypeScript. Ищем в этом файле baseUrl и paths и заменяем их на следующее:

"baseUrl": "./",
"paths": {
  "@pages/*": [
    "page_object/pages/*"
  ],
  "@components/*": [
    "page_object/components/*"
  ]
}

Это позволит нам импортировать наши будущие страницы и компоненты относительно указаных путей, чтобы не городить импорты вида ../../../../../base.page.ts

Открываем playwright.config.ts и добавляем baseURL: 'https://www.candlapp.com' в use секцию. Все пути до наших страниц будут формироваться относительно этого baseUrl. Я так же рекомендую сразу настроить eslint и prettier, но останавливаться подробно на этом не будем. На этом с подготовкой закончено, можно сделать новый commit

git add .
g commit -m "config"

Переходим к оформлению PO. Создаем папку page_object в корне проекта. Внутри page_object сразу создаем 2 папки для компонентов и страниц - components и pages. Давайте для начала создадим простой компонент Header, который будет описывать header. Создаем файл header.component.ts в папке components:

import { Dorama } from 'dorama';

export class HeaderComponent extends Dorama.Component {
  readonly signInButton = this.locator('.menu a', { hasText: 'SIGN IN' });
}

Здесь мы импортируем Dorama и наследуем наш класс HeaderComponent от Dorama.Component. Добавляем сразу 1 простой локатор signInButton. Локатор создается через запись this.locator, но он имеет такую же сигнатуру как и locator playwright. И возвращает он тоже Locator, поэтому в тестах с signInButton можно работать как с обычным локатором playwright.

Далее создаем базовую страницу внутри папки pages - base.page.ts. В этом классе будут лежать все локаторы и компоненты, которые используются по всему сайту (header, footer, диалоги и тд). Так, к этим элементам будет доступ с любых страниц. Создание экземпляра BasePage не предполагается, поэтому делаем его abstract:

import { Dorama } from "dorama";
import { HeaderComponent } from "@components/header.component";

export abstract class BasePage extends Dorama.Page { 
	readonly header = this.component(HeaderComponent, "header");
}

Здесь мы импортируем Dorama, а так же наш компонент HeaderComponent. Наследуем нашу страницу BasePage от Dorama.Page. Dorama.Page обязует нас реализовать метод url, который будет использоваться для перехода на страницу. Однако, так как наш класс также является абстрактным, здесь нам это делать не нужно. Сюда же мы добавляем наш компонент header через запись this.component(HeaderComponent, "header»), который возвращает экземпляр класса HeaderComponent. Важный момент - когда мы будем обращаться к signInButton через header, локатор до signInButton будет построен относительно контейнера header, который мы передаем вторым аргументом в this.component. Все наши будущие страницы мы будем наследовать от BasePage.

Давайте теперь создадим IndexPage:

import { BasePage } from "@pages/base.page";

export class IndexPage extends BasePage {
  url = () => "/";

  readonly startNowButton = this.locator("a");
}

Экземпляр класса IndexPage нам создавать придется, поэтому нас обязывают реализовать метод url. В данном случае метод просто возвращает «/» для индексной страницы. Далее мы увидим как реализовать этот метод для навигации на страницы с динамическими url.

Чтобы было удобно ориентироваться во всех страницах нашего POM, рекомендуется организовать иерархию директорий так, чтобы они повторяли путь в url. Например, для страницы https://www.candlapp.com/users/sign_in, нужно создать папку user, внутри нее sign_in и положить там страницу signIn.page.ts:

import { BasePage } from "@pages/base.page";
import { test } from "@playwright/test";

export class SignInPage extends BasePage {
  url = () => "/users/sign_in";

  readonly emailField = this.locator("#user_email");
  readonly passwordField = this.locator("#user_password");

  readonly signInButton = this.locator('input[type="submit"]');

  loginAs = async (email: string, password: string) => {
    await test.step(`Login with credentials ${email}/${password}`, async () => {
      await this.emailField.fill(email);
      await this.passwordField.fill(password);

      await this.signInButton.click();
    });
  };
}

Тут несколько простых локаторов и метод для авторизации. 

Теперь посмотрим, как описать более сложную страницу https://www.candlapp.com/app/#/author/author_id:

import { BasePage } from "@pages/base.page";
import { BookItemComponent } from "@components/books/bookItem.component";

export class AuthorPage extends BasePage {
  url = (routes: { authorId: string }) => `/app/#/author/${routes.authorId}`;

  readonly loader = this.locator(".loader");

  readonly booksItems = this.components(
    BookItemComponent,
    ".author-book-list .list-book-item",
  );
}

Метод url() принимает параметр routes: { authorId: string }), который позволит нам заходить на страницу конкретного автора, например https://www.candlapp.com/app/#/author/48 

Помимо простого локатора loader, страница содержит booksItems. Метод this.components() возвращает экземлпяр класса Components, который содержит список всех компонентов BookItemComponent, которых на странице много. Components предлагает удобное api для работы со списком компонентов, в том числе асинхронные - filter, find, map и тд. Это позволит удобно фильтровать список компонентов в тесте, например:

const hellBentBook = await authorPage.booksItems.find(async book => (await book.title.innerText()) === "Hell Bent");
await hellBentBook.title.click();

То есть, находим среди всех книг на странице нужную и кликаем именно на нее. Мы так же можем получить любую книгу по индексу:

await authorPage.booksItems.nth(5).title.click();

Компонент BookItemComponent выглядит следующим образом:

import { Dorama } from "dorama";

export class BookItemComponent extends Dorama.Component {
  readonly cover = this.locator(".cover");
  readonly title = this.locator(".list-book-item-title");
  readonly data = this.locator(".book-info-data");
}

Остальные страницы и компоненты строятся аналогично, с любой необходимой вложенностью. Получается, что и страницы и компоненты будут состоять из локаторов (this.loctor()), компонентов (this.component()), списка компонентов (this.components()). Также, для читаемости кода, есть метод this.locators(), который возвращает массив простых локаторов (но вообще-то он просто возвращает locator.all()). Таким образом, мы строим наши страницы и компоненты с использованием наследования и композиции.

Давайте теперь к тестам. Работать со страницами будем через фикстуры. В папке tests создадим base.test.ts и заполним его:

import { test as base } from "@playwright/test";
import { IndexPage } from "@pages/index.page";
import { SignInPage } from "@pages/users/sign_in/signIn.page";
import { AuthorPage } from "@pages/app/authors/author.page";

type Pages = {
  indexPage: IndexPage;
  signInPage: SignInPage;
  authorPage: AuthorPage;
};

export const test = base.extend<Pages>({
  indexPage: async ({ page }, use) => {
    await use(new IndexPage(page));
  },
  signInPage: async ({ page }, use) => {
    await use(new SignInPage(page));
  },
  authorPage: async ({ page }, use) => {
    await use(new AuthorPage(page));
  },
});

Импортируем playwright test и наши страницы. Создаем тип Pages, описывающий наши страницы и делаем extend объекта test playwright-a. Внутри фикстур просто создаем экземпляры наших страниц.

Посмотрим, как выглядит тест (сам тест смысла не имеет, но для демонстрации годится):

import { test } from "./base.test";
import { expect } from "@playwright/test";

const login = "";
const password = "";

test("Login and go to author 48", async ({
  indexPage,
  signInPage,
  authorPage,
  page,
}) => {
  await test.step(`Login as user`, async () => {
    await indexPage.goto();

    await indexPage.header.signInButton.click();
    await expect(page).toHaveURL(signInPage.url());

    await signInPage.loginAs(login, password);
  });

  await test.step(`Go to author and check books`, async () => {
    await authorPage.goto({ authorId: "48" });

    await authorPage.loader.waitFor({ state: "hidden" });

    const actualBooksCount = await authorPage.booksItems.count();
    expect(actualBooksCount).toBe(100);
  });
});

Обратите внимание, метод goto() мы нигде не реализовывали. Его предоставляет Dorama, которая использует url, который мы описываем на страницах. Важный момент - чтобы typescript понимал и подсвечивал параметры в goto() (authorId в примере выше), нужно добавить немного типизации. Модифицируйте BasePage к такому виду:

export abstract class BasePage<T extends Record<string, string> = Record<string, string>> extends Dorama.Page

а AuthorPage к такому:

type UrlType = { authorId: string };

export class AuthorPage extends BasePage<UrlType> {
  url = (routes: UrlType) => `/app/#/author/${routes.authorId}`;
}

Спасибо за внимание!

Готовый код с тестами можно найти тут

Описание API Dorama тут

Tags:
Hubs:
Total votes 7: ↑6 and ↓1+5
Comments4

Articles