Привет! Меня зовут Денис Кудряшов, я инженер по тестированию в Waves Enterprise. В этом посте я расскажу о концепции PageObject, прокомментирую утверждения ее создателя Мартина Фаулера, а в конце расскажу, как мы в компании расширили PageObject и пришли к концепции Testing entity Definition Object (TDO).
В 2013 году Мартин Фаулер написал статью, в которой описал объекты PageObject как сущности, предоставляющие нам API, чтобы не копаться в HTML-коде страницы, а сразу эмулировать ее поведение и взаимодействовать с ней из кода так же, как это делал бы при помощи браузера обычный человек. В статье Фаулера было несколько замечаний:
Для каждой HTML-страницы может быть создано больше одного PageObject.
Допустимо создание дочерних PageObject в родительских страницах, например, при навигации или в карточках товаров каталога.
Объекты не связаны между собой и с тестами (не содержат ассертов)
Из этих трех утверждений я согласен только с последним. PageObject — это абстракция, описывающая тестируемую сущность. Если начать вставлять туда какие-то проверки, то в будущем обязательно возникнет необходимость дополнения и/или изменения состава проверок. Отсутствие ассертов нужно, чтобы отвязать PageObject от самих тестов и дать возможность тестировщику переиспользовать объекты в других тестах. А вот к остальным утверждениям есть вопросы.
Далее я на примере нескольких сценариев тестирования прокомментирую первые два суждения Фаулера и покажу, как еще можно применять PageObject и развить его идею до концепции Testing entity Definition Object (TDO).
Тест-кейс UI
Рассмотрим стандартный пример — простой тест страницы авторизации, стандартной формы с логином и паролем. Нужно написать автотест, проверяющий позитивный сценарий.
Тест-кейс простой:
-
Открываем страницу.
Страница открылась.
На странице имеются поля «Логин» и «Пароль», а также кнопка «Войти».
-
Вводим логин в поле «Логин».
В поле «Логин» отображается введенное значение.
-
Вводим пароль в поле «Пароль».
В поле «Пароль» отображается введенное значение.
-
Нажимаем кнопку «Войти».
Открылась страница личного кабинета.
На странице отображаются имя и фамилия пользователя.
Код теста (здесь и далее приведен на JavaScript):
it('I can authorize into sign in form', async () => {
const signinPage = new SigninPage(page);
await step('load sign in page', async () => {
await signinPage.load();
expect (signinPage.getLoginField()).not.undefined;
//…
});
//…
await step('send form', async () => {
await signinPage.signin();
const userPage = new UserPage(page);
expect(userPage.getUsernameText()).to.be.equal(login);
});
});
Здесь мы создаем PageObject, в котором описано поведение страницы при вводе логина, пароля и нажатии кнопки авторизации. По ходу теста мы используем эти функции, а в конце проверяем, что при корректном вводе логина и пароля был осуществлен переход в личный кабинет пользователя. Сам PageObject выглядит так:
export class SigninPage {
readonly page: Page;
readonly path: string = '/login';
//…
readonly buttonCss: string = 'form.signin input[type=submit]';
//…
signinButton: Locator | undefined;
constructor(page: Page) {
this.page = page;
}
async load() {
await this.page.goto(env.host + this.path);
//…
this.signinButton = this.page.locator(this.buttonCss);
}
//…
}
В нем важно отметить две группы элементов: описание нашей страницы (ее элементы и свойства) и описание поведения — то есть того, что пользователь может делать со страницей. Когда мы начнем писать end-to-end тесты через PageObject, то один PageObject будет соответствовать одному тестируемому объекту (странице), а объекты не будут связаны между собой, что удобно.
Если мы проходим путь пользователя по странице и бизнес-логика в какой-то момент меняется, то оперировать сущностями меньше или больше, чем страница, становится неудобно. Поэтому придерживаемся правила: один тестируемый объект — один PageObject.
Тест-кейс UI+Backend
Попробуем теперь применить принципы создания PageObject в тестах API на примере простого интеграционного теста на слоях UI + Backend. На этот раз возьмем страницу сборки товара, которая просто предоставляет нам информацию о том, что у нас со сборкой происходит. Тест-кейс будет выглядеть так:
-
Открываем страницу заказа.
Страница открылась.
Статус заказа = new.
-
Изменяем статус заказа (через мобильное приложение сборщика, например) путем отправки запроса на REST API.
Ответ API “200 ok”.
В ответе JSON поле status = progress.
-
Перезагружаем страницу заказа.
Статус заказа изменился на progress.
Тест-кейс по структуре особо отличаться не будет. Мы точно так же используем объекты, описывающие API, поведение (формирование запроса и получения ответа) и проверяем некоторые свойства, присущие объекту:
it('I can view status changing', async () => {
await step(open order page', async () => {
//…
});
await step('change order status through API', async () => {
const orderApi = new OrderApi();
const response = await OrderApi.changeStatus(order_id, 'progress');
expect (response.statusCode).to.be.equal(200);
expect (response.body.status).to.be.equal('progress');
});
await step('reload page and check status', async () => {
//…
});
});
Код API object:
export class OrderApi {
readonly path: string = '/order api';
response: any;
async changeStatus(order_id; number, new_status: string) {
this.response = await new JsonRequest()
.url(env.host + this.path).method('POST')
.body({
order_id: order_id,
new_status: new_status
}).send();
return this.response;
}
//…
}
То же самое можно сделать для gRPC или SOAP, особых отличий не будет.
Что важно в этом примере? Фактически мы используем одни и те же принципы для описания разных сущностей. Если у нас один объект похож на другой, то, скорее всего, они принадлежат к одному типу объектов. Мы с коллегами решили назвать его Testing entity Definition Object (TDO) — класс, в котором описывается реальный тестовый объект, его свойства, элементы и интерфейс взаимодействия. Для описания таких объектов мы использовали те же принципы, что применяются при создании PageObject.
Как мы создаем TDO
В классе TDO мы описываем свойства, методы, формируем поведение и добавляем ссылку на объект клиента, при помощи которого мы будем взаимодействовать с тестируемым объектом. Больше в этом классе ничего не содержится.
Используя методы описанного объекта, мы через клиент воздействуем на тестируемый объект. Он реагирует и возвращает нам состояние, которое мы сохраняем в свойствах TDO. В тесте мы можем получить значения свойств TDO и проверить актуальное состояние тестируемого объекта. С учетом вышесказанного, схема тестового проекта целиком выглядит так:
Описание тестового объекта размещено отдельно в репозиториях или пакетах, которые можно свести в библиотеку. Тест-кейсы отделены от описаний и отвечают исключительно за формирование шагов кейса, в которых мы проводим какие-то действия и проверяем полученный результат. Также существует слой тестовых инструментов: клиенты, которые используются в TDO, модули интеграции с TMS, CI/CD и т.д.
При такой структуре тестового репозитория мы можем отказаться от кастомных фреймворков. Иногда это может сильно облегчить нам жизнь. Но это, скорее, приятный бонус.
Подходят ли TDO для описания любых тестируемых сущностей?
Чтобы ответить на этот вопрос, проведем еще один небольшой тест. В нем мы будем использовать запрос в API и подключение к базе. Вот наш тест-кейс:
-
Проверим в БД статус новой сборки.
Поле status = new.
-
Изменяем статус заказа путем отправки запроса на REST API.
Ответ API “200 ok”.
В ответе JSON поле status = progress.
-
Проверим в БД статус текущей сборки.
Поле status = progress.
Все как и в прошлый раз, только мы меняем статус через API, а проверяем его изменение через запрос в базу данных. Код тест-кейса:
it('I can change status through API', async () => {
const orderDb = await orderDB.findOneBy({ id: order_id});
await step('Check order status in DB', async () => {
expect (orderDb.status).to.be.equal('new');
});
await step('Change order status through API', async () => {
//…
});
await step('Check order status in DB again', async () => {
orderDb.reload();
expect(orderDb.status).to.be.equal('progress');
});
});
Код DB TDO:
@Entity
export class Order Db extends BaseEntity {
@PrimaryGeneratedColumn()
id: number | undefined;
@Column()
status: string | undefined;
constructor() {
super();
}
}
TDO объекта будет еще проще, мы указываем здесь только свойства объекта, а все поведение уже инкапсулировано в базовом классе BaseEntity. Если используются нестандартные хранилища, можно описать своё поведение. Таким образом, получается, что класс объектов TDO отлично работает в любых кейсах.
Преимущества TDO
По сути, TDO — это расширение паттерна PageObject, который удобно применять в автотестах. Описание нашего тестируемого объекта, согласно схеме, вынесено отдельно.
Если приравнивать качество к совокупности характеристик, то, управляя набором характеристик и поведением, описанным в TDO, мы можем задавать уровень качества в нашей модели и проверять объект на соответствие этому уровню. То есть мы управляем качеством выпускаемого ПО более гибко и удобно.
Бонус использования TDO — это появление еще одного инструмента для тестирования требований. Когда мы смотрим, какое поведение у нас реализовано, мы можем понять, насколько мы выполнили задачу, насколько наш тестируемый продукт соответствует изначальным требованиям бизнеса или заказчика.
Другой бонус — единообразие тестов. Независимо от того, на каком языке или слое мы пишем автотесты, все они похожи друг на друга. Это упрощает онбординг новых сотрудников и позволяет по необходимости легко проводить ротацию кадров, когда нужно усилить какие-то проекты. Мы также можем легко вынести тестовые инструменты за пределы проектов и использовать только необходимые модули. Автотесты становятся стабильней и проще в поддержке. Как результат — скорость написания тестов у нас в команде увеличилась на 20%.
Напоследок прилагаю ссылки с примерами использования TDO на TypeScript и Java.