Screenplay — не Page Object’ом единым

в 15:08, , рубрики: javascript, screenplay, selenium, selenium-webdriver, serenityjs, Блог компании Аркадия, Тестирование IT-систем, Тестирование веб-сервисов

Со временем вносить изменения в любой продукт становится сложнее, и растёт риск не только зарелизить новые фичи, но и сломать старые. Часто вместо того, чтобы руками проверять весь проект, этот процесс стараются автоматизировать. Если поговорить с людьми, которые занимаются тестированием интерфейсов, походить по конференциями, становится понятно, что в мире веб-тестирования правит Selenium, а в качестве организации кода подавляющее большинство используют Page Object.

Вот только мне, как программисту, этот паттерн и код, который я видел у разных команд, почему-то никогда не нравился — в голове звучали буквы SOLID. Но я уже был готов смириться с тем, что тестировщики пишут код, как им удобно, из-за отсутствия альтернатив, как где-то год назад, на Angular Connect, услышал я доклад, посвящённый тестированию Angular приложений c использованием Screenplay паттерна. Теперь хочу поделиться.

Screenplay — не Page Object'ом единым - 1

Подопытный кролик

Для начала небольшое описание используемого инструментария. В качестве примера реализации я возьму SerenityJS из оригинального доклада, с TypeScript в качестве языка тест-скриптов.

Опыты мы будем ставить над TODO приложением — приложением для созданием простого списка задач. В качестве примеров будет использоваться код от Яна Молака, создателя библиотеки (код на TypeScript, поэтому подсветка немного поехавшая).

Начнём в 2009-м году

В это время появился Selenium WebDriver, и люди начали им пользоваться. К сожалению, в большом количестве случаев, в силу новизны технологии и отсутствия программистского опыта — неправильно. Тесты получались полными копипаста, неподдерживаемыми и «хрупкими».

Результат — негативный фидбек и плохая репутация у автотестов. В итоге, Саймон Стюарт, создатель Selenium WebDriver, заочно ответил на это в своей статье My Selenium Tests Aren't Stable!. Общий посыл: «если ваши тесты хрупкие и плохо работают — это не из-за Selenium, а потому что сами тесты написаны не очень».

Чтобы понять, о чём идёт речь, давайте глянем на следующий пользовательский сценарий:

Feature: Add new items to the todo list

  In order to avoid having to remember things that need doing
  As a forgetful person
  I want to be able to record what I need to do in a place where I won't forget about them

  Scenario: Adding an item to a list with other items

    Given that James has a todo list containing Buy some cookies, Walk the dog
      When he adds Buy some cereal to his list
      Then his todo list should contain Buy some cookies, Walk the dog, Buy some cereal

Наивная имплементация «в лоб» будет выглядеть следующим образом:

import { browser, by, element, protractor } from 'protractor';

export = function todoUserSteps() {

    this.Given(/^.*that (.*) has a todo list containing (.*)$/,
        (name: string, items: string, callback: Function) => {

            browser.get('http://todomvc.com/examples/angularjs/');
            browser.driver.manage().window().maximize();

            listOf(items).forEach(item => {
                element(by.id('new-todo')).sendKeys(item, protractor.Key.ENTER);
            });

            browser.driver.controlFlow().execute(callback);
    });

    this.When(/^s?he adds (.*?) to (?:his|her) list$/,
        (itemName: string, callback: Function) => {

            element(by.id('new-todo'))
                .sendKeys(itemName, protractor.Key.ENTER)
                .then(callback);
    });

    this.Then(/^.* todo list should contain (.*?)$/,
        (items: string, callback: Function) => {
            expect(element.all(by.repeater('todo in todos')).getText())
                .to.eventually.eql(listOf(items))
                .and.notify(callback);
    });
};

Как видим, тут используется низкоуровневое API, манипуляции с DOM'ом, копипаста css-селекторов. Понятно, что в будущем даже при небольшом изменении UI придётся менять код во многих местах.

В качестве решения Selenium должен был предложить что-то достаточно хорошее, чтобы избавится от таких проблем, и вместе с тем доступное людям с небольшим или вообще без опыта объектно-ориентированного программирования. Таким решением стал Page Object — паттерн для организации кода.

Мартин Фаулер описывает его как объект-абстракцию вокруг HTML страницы или её фрагмента, позволяющий взаимодействовать с элементами страницы, не трогая сам HTML. В идеале, такой объект должен позволять клиентскому коду делать и видеть всё то же, что может делать и видеть человек.
Screenplay — не Page Object'ом единым - 2

Переписав наш пример в соответствии с этим паттерном, имеем следующее:

import { browser, by, element, protractor } from 'protractor';

class TodoList {
    What_Needs_To_Be_Done = element(by.id('new-todo'));
    Items = element.all(by.repeater('todo in todos'));

    addATodoItemCalled(itemName: string): PromiseLike<void> {
        return this.What_Needs_To_Be_Done.sendKeys(itemName, protractor.Key.ENTER);
    }

    displayedItems(): PromiseLike<string[]> {
        return this.Items.getText();
    }
}

export = function todoUserSteps() {

    let todoList = new TodoList();

    this.Given(/^.*that (.*) has a todo list containing (.*)$/, 
        (name: string, items: string, callback: Function) => {

            browser.get('http://todomvc.com/examples/angularjs/');
            browser.driver.manage().window().maximize();

            listOf(items).forEach(item => {
                todoList.addATodoItemCalled(item);
            });

            browser.driver.controlFlow().execute(callback);
    });

    this.When(/^s?he adds (.*?) to (?:his|her) list$/,
        (itemName: string, callback: Function) => {
            todoList.addATodoItemCalled(itemName).then(() => callback());
    });

    this.Then(/^.* todo list should contain (.*?)$/,
        (items: string, callback: Function) => {
            expect(todoList.displayedItems())
                .to.eventually.eql(listOf(items))
                .and.notify(callback);
    });
};

На первый взгляд, выглядит хорошо — мы избавились от дублирования и добавили инкапсуляции. Но есть одна проблема: очень часто такие классы в процессе разработки разрастаются до огромных размеров.

Screenplay — не Page Object'ом единым - 3

Можно попробовать декомпозировать этот код на независимые компоненты, но если код дошёл до такого состояния, он уже настолько сильно связан, что это может быть очень тяжело и потребует переписывания как самого класса, так и всего клиентского кода (тест-кейсы), которые используют этот объект. На самом деле огромный класс — это не болезнь, а симптом (code smell). Главная проблема — нарушение Single Responsibility Principle и Open Closed Principle. Page Object подразумевает описание страницы и все пути взаимодействия с ней в одной сущности, которую невозможно расширить, не изменив её код.

Для нашего TODO приложения ответственности класса выглядят следующим образом (картинки из статьи):
Ответственности

Если попробовать разделить этот класс, основываясь на принципах SOLID, получится примерно следующее:
Screenplay — не Page Object'ом единым - 5

Доменная модель

Следующая проблема, которую мы имеем с Page Object'ом — он оперирует страницами. В любом приёмочном тестировании же обычным является использование пользовательских сценариев (user stories). Behavior Driven Development (BDD) и язык Gherkin прекрасно ложатся на эту модель. Подразумевается, что путь пользователя к цели (сценарий) более важен, нежели конкретная имплементация. Например, если у вас во всех тестах используется виджет логина, при изменении способа логина (форма переехала, перешли на Single Sign On) вам придётся поменять все тесты. В особо динамичных проектах это может оказаться затратно и долго и сподвигнуть команду отодвинуть написание тестов, пока страницы не стабилизируются (даже если со сценариями использования все ясно).

Чтобы решить эту проблему, стоит взглянуть на неё с другой стороны. Введём следующие понятия:

  1. Роли (Roles) — для кого это всё?
  2. Цели (Goals) — почему они (пользователи) здесь и чего они хотят добиться?
  3. Задачи (Tasks) — что им нужно сделать, чтобы достигнуть этих целей?
  4. Действия (Actions) — как конкретно пользователь должен взаимодействовать со страницей, чтобы выполнить задачу?

Таким образом, каждый тест-сценарий становится, де-факто, сценарием для пользователя, направленным на исполнение конкретной user story. Если совместить этот подход с принципами объектно-ориентированного программирования, описанными выше, результат и будет являться Screenplay (или User Journey) паттерном. Его идеи и принципы впервые озвучивались в 2007 году, ещё до PageObject.

Screenplay

Перепишем наш код с использованием получившихся принципов.

Джеймс — актёр, который будет играть нашего пользователя в этом сценарии. Он умеет пользоваться браузером.

let james = Actor.named('James').whoCan(BrowseTheWeb.using(protractor.browser));

Цель Джеймса — добавить первый элемент в свой список:

 Scenario: Adding an item to a list with other items

Для того, чтобы реализовать эту цель, Джеймсу нужно следующее:

  1. начать со списка, содержащего несколько элементов,
  2. добавить новый элемент.

Можем разбить это на два класса:

import { PerformsTasks, Task } from 'serenity-js/lib/screenplay';
import { Open } from 'serenity-js/lib/screenplay-protractor';

import { AddATodoItem } from './add_a_todo_item';

export class Start implements Task {

    static withATodoListContaining(items: string[]) {
        return new Start(items);
    }

    performAs(actor: PerformsTasks) {
        return actor.attemptsTo(
            Open.browserOn('/examples/angularjs/'),
            ...this.addAll(this.items)
        );
    }

    constructor(private items: string[]) {
    }

    private addAll(items: string[]): Task[] {
        return items.map(item => AddATodoItem.called(item));
    }
}

Интерфейс Task требует определить метод performAs, куда во время исполнения будет передан актор. attemptsTo — функция-комбинатор, принимает любое количество тасок. Таким образом можно выстраивать самые разные последовательности. По сути, всё, что делает эта таска, это открывает браузер на нужной странице и добавляет туда элементы. Посмотрим теперь на таску добавления элемента:

import { PerformsTasks, Task } from 'serenity-js/lib/screenplay';
import { Enter } from 'serenity-js/lib/screenplay-protractor';

import { protractor } from 'protractor';

import { TodoList } from '../components/todo_list';

export class AddATodoItem implements Task {

    static called(itemName: string) {
        return new AddATodoItem(itemName);
    }

    // required by the Task interface
    performAs(actor: PerformsTasks): PromiseLike<void> {
        // delegates the work to lower-level tasks
        return actor.attemptsTo(
            Enter.theValue(this.itemName)
                .into(TodoList.What_Needs_To_Be_Done)
                .thenHit(protractor.Key.ENTER)
        );
    }

    constructor(private itemName: string) 
    }
}

Тут уже интереснее, появляются низкоуровневые действия — ввести текст в элемент страницы и нажать на Enter. TodoList — это всё, что осталось от «описательной» части Page Object'а, здесь у нас лежат css-селекторы:

import { Target, Question, Text } from 'serenity-js/lib/screenplay-protractor';

import { by } from 'protractor';

export class TodoList {
    static What_Needs_To_Be_Done = Target
        .the('"What needs to be done?" input box')
        .located(by.id('new-todo'));

    static Items = Target
        .the('List of Items')
        .located(by.repeater('todo in todos'));

    static Items_Displayed = Text.ofAll(TodoList.Items);
}

Ок, осталось проверить, что после всех манипуляций отображается правильная информация. SerenityJS предлагает интерфейс Question<T> — сущность, возвращающая отображаемое значение или список значений (Text.ofAll в примере выше). Как можно было бы реализовать такой вопрос, возвращающий текст HTML элемента:

export class Text implements Question<string> {

    public static of(target: Target): Text {
        return new Text(target);
    }

    answeredBy(actor: UsesAbilities): PromiseLike<string[]> {
        return BrowseTheWeb.as(actor).locate(this.target).getText();
    }

    constructor(private target: Target) {
    }
}

Что важно, привязка к браузеру необязательна. BrowseTheWeb это всего лишь Ability, которая позволяет взаимодействовать с браузером. Можно реализовать, например, RecieveEmails ability, которая позволит актору читать письма (для регистрации на сайте).

Собрав всё вместе, получаем такую схему (от Яна Молака):
Screenplay — не Page Object'ом единым - 6

и следующий сценарий:

let actor: Actor;

this.Given(/^.*that (.*) has a todo list containing (.*)$/, function (name: string, items: string) {
    actor = Actor.named(name).whoCan(BrowseTheWeb.using(protractor.browser));

    return actor.attemptsTo(
        Start.withATodoListContaining(listOf(items))
    );
});

this.When(/^s?he adds (.*?) to (?:his|her) list$/, function (itemName: string) {
    return actor.attemptsTo(
        AddATodoItem.called(itemName)
    )
});

this.Then(/^.* todo list should contain (.*?)$/, function (items: string) {
    return expect(actor.toSee(TodoList.Items_Displayed)).eventually.deep.equal(listOf(items))
});

Поначалу получившийся результат выглядит несколько массивно, однако по мере роста системы будет увеличиваться количество переиспользуемого кода, а изменения верстки и страниц будут влиять только на css селекторы и низкоуровневые таски / вопросы, оставляя сами скрипты и высокоуровневые таски практически неизменёнными.

Реализации

Если говорить о библиотеках, а не о местечковых попытках применения этого паттерна, наиболее популярной является Serenity BDD для Java. Под JavaScript/TypeScript используется её же порт SerenityJS. Из коробки она умеет работать с Cucumber и Mocha.

Беглый поиск также выдал библиотеку для .NET — tranquire. Не могу о ней ничего сказать, так как раньше не встречал.

При использовании Serenity и SerenityJS можно воспользоваться утилитой генерации репортов.

Репорт с картинками

Возьмём код с использованием Mocha:

describe('Finding things to do', () => {
    describe('James can', () => {
        describe('remove filters so that the list', () => {
            it('shows all the items', () => Actor.named('James').attemptsTo(
                Start.withATodoListContaining([ 'Write some code', 'Walk the dog' ]),
                CompleteATodoItem.called('Write some code'),
                FilterItems.toShowOnly('Active'),
                FilterItems.toShowOnly('All'),
                Ensure.theListOnlyContains('Write some code', 'Walk the dog'),
            ));
        });
    });
});

Репорт будет содержать как общую статистику:
Screenplay — не Page Object'ом единым - 7

так и разбивку по шагам со скриншотами:
Screenplay — не Page Object'ом единым - 8

Ссылки

Несколько статей по теме:

  1. Page Objects Refactored: SOLID Steps to the Screenplay/Journey Pattern
  2. Beyond Page Objects: Next Generation Test Automation with Serenity and the Screenplay Pattern
  3. Serenity BDD and the Screenplay Pattern

Доклады:

Библиотеки:

  1. Serenity BDD — Java
  2. Serenity JS — JavaScript/TypeScript
  3. tranquire — .NET

Пишите, пожалуйста, в комментариях, если используете иные паттерны / способы организации кода автотестов.

Автор: sAntee

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js