Как стать автором
Обновить
561.62
Рейтинг
OTUS
Цифровые навыки от ведущих экспертов

Перестаньте использовать Page Objects (РО) и начните использовать App Actions

OTUSТестирование IT-системJavaScript
Перевод
Автор оригинала: Gleb Bahmutov

Привет, хабровчане. Для будущих студентов курса «JavaScript QA Engineer» подготовили перевод полезного материала.

Также приглашаем всех желающих принять участие в
открытом вебинаре на тему «Что нужно знать о JS тестировщику». На занятии будет рассмотрены особенности JS, которые всё время нужно держать в голове при написании тестов.


Написание поддерживаемых сквозных тестов — это сложная задача. Часто тестировщики создают другой косвенный слой веб-страницы, называемый page objects, для выполнения общих действий. В этой статье я утверждаю, что page objects — это плохая практика, и предлагаю непосредственно обратить внимание на внутренний алгоритм работы приложения. Это отлично работает с современным test runner Cypress.io, который запускает тестовый код непосредственно вместе с кодом приложения.

Page objects

Page Objects 1, 2 предназначены для того, чтобы делать сквозные тесты читабельными и простыми в эксплуатации. Вместо ad-hoc интеракций со страницей, тест управляет страницей с помощью экземпляра приложения, который представляет собой пользовательский интерфейс страницы. Например, здесь абстракция страницы входа взята непосредственно со страницы Selenium Wiki.

public class LoginPage {
  private final WebDriver driver;

  public LoginPage(WebDriver driver) {
    this.driver = driver;

    // Check that we're on the right page.
    if (!"Login".equals(driver.getTitle())) {
      // Alternatively, we could navigate to the
      // login page, perhaps logging out first
      throw new IllegalStateException("This is not the login page");
    }
  }

  // The login page contains several HTML elements
  // that will be represented as WebElements.
  // The locators for these elements should only be defined once.
  By usernameLocator = By.id("username");
  By passwordLocator = By.id("password");
  By loginButtonLocator = By.id("login");

  // The login page allows the user to type their
  // username into the username field
  public LoginPage typeUsername(String username) {
    // This is the only place that "knows" how to enter a username
    driver.findElement(usernameLocator).sendKeys(username);

    // Return the current page object as this action doesn't
    // navigate to a page represented by another PageObject
    return this;
  }
  // other methods
  //  - typePassword
  //  - submitLogin
  //  - submitLoginExpectingFailure
  //  - loginAs
}

Page Objects имеют два основных преимущества:

  1. Они сохраняют все селекторы элементов страницы в одном месте

  2. Они стандартизируют взаимодействие тестов со страницей

Типовой тест будет содержать такие Page Objects:

public void testLogin() {
  LoginPage login = new LoginPage(driver);
  login.typeUsername('username')
  login.typePassword('username')
  login.submitLogin()
}

Мартин Фаулер в своей статье описывает PageObjects как еще один API помимо HTML. Концептуально PageObjects дополняют HTML.

Tests
-----------------
  Page Objects
~ ~ ~ ~ ~ ~ ~ ~ ~
    HTML UI
-----------------
Application code

Четыре уровня на диаграмме выше имеют три интерфейса с различным уровнем связанности.

1. Связь между кодом приложения и HTML высокая.

2. Между HTML и page objects низкая.

3. Между тестами и PO высокая

Связность между кодом приложения и HTML UI очень высокая, потому что код выводит HTML-элементы в DOM — то есть между функцией render в коде и элементом-вывода DOM связь one to one. Статические типы и линтеры помогают обеспечить согласованность кода приложения и создавать содержательный HTML.

Page objects и HTML слабо связаны, вот почему я провел границу, используя ~ ~. Они используют селекторы, чтобы найти элементы, который НЕ проверяются каким-либо линтером или компиляром кода. Код приложения в любой момент может поменяться, выдать другую DOM структуру или другие элементы классов, а также тесты могут прерваться во время выполнения без предупреждения.

Это отличная возможность для тех, кто хочет написать линтер, который будет увеличивать связь между результатом HTML и селекторов, используемых в тестах.

И наконец, связь между page objects и тестами очень высокая — так как оба уровня в том же самом коде и могут проверяться компилятором, чтобы не было программных ошибок.

Page objects в Cypress

Вы можете с легкостью использовать Page Objects в тестах Cypress. Вот типовой пример из статьи “Deep diving PageObject pattern and using it with Cypress”. Типовой класс PageObject SignInPage схож с LoginPage из Selenium, что был показан выше.

class SignInPage {
  visit() {
    cy.visit('/signin');
  }

  getEmailError() {
    return cy.get(`[data-testid=SignInEmailError]`);
  }

  getPasswordError() {
    return cy.get(`[data-testid=SignInPasswordError]`);
  }

  fillEmail(value) {
    const field = cy.get(`[data-testid=SignInEmailField]`);
    field.clear();
    field.type(value);

    return this;
  }

  fillPassword(value) {
    const field = cy.get(`[data-testid=SignInPasswordField]`);
    field.clear();
    field.type(value);

    return this;
  }

  submit() {
    const button = cy.get(`[data-testid=SignInSubmitButton]`);
    button.click();
  }
}

export default SignInPage;

Когда мы готовим тест для “Home page”, мы можем еще раз использовать SignInPage из другого Page Object.

import Header from './Headers';
import SignInPage from './SignIn';

class HomePage {
  constructor() {
    this.header = new Header();
  }

  visit() {
    cy.visit('/');
  }

  getUserAvatar() {
    return cy.get(`[data-testid=UserAvatar]`);
  }

  goToSignIn() {
    const link = this.header.getSignInLink();
    link.click();

    const signIn = new SignInPage();
    return signIn;
  }
}

export default HomePage;

Это типичный сценарий — вам необходимо написать полностью иерархию класса PageObject, где все части страницы использую разные объекты страницы, создавая их, используя object-oriented дизайн. Типовой тест тогда выглядит так:

import HomePage from '../elements/pages/HomePage';

describe('Sign In', () => {
  it('should show an error message on empty input', () => {
    const home = new HomePage();
    home.visit();

    const signIn = home.goToSignIn();

    signIn.submit();

    signIn.getEmailError()
      .should('exist')
      .contains('Email is required');

    signIn
      .getPasswordError()
      .should('exist')
      .contains('Password is required');
  });

  // more tests
});

Cypress включает в себя бантлер JavaScript, и таким образом работает код, показанный выше.

Вам не нужно использовать object-oriented PageObject. Вы также можете переместить перевести типичную логику в режим многоразового использования Cypress Custom Commands, которые не имеют никакого внутреннего положения и просто позволяют использовать код повторно. Например, вы можете запустить команду “login”.

// in cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
  cy.get('#login-username').type(username)
  cy.get('#login-password').type(password)
  cy.get('#login').submit()
})

После добавления пользовательских команд, тесты их могут использовать также, как и команду built-in.

// cypress/integration/spec.js
it('logs in', () => {
  cy.visit('/login')
  cy.login('username', 'password')
})

Заметьте, что вам не надо всегда создавать пользовательские команды, а достаточно просто работать с функциями JavaScript (может это даже и лучше, потому что тип check step может определять индивидуальные подписи функции.

// cypress/integration/util.js
export const login = (username, password) => {
  cy.get('#login-username').type(username)
  cy.get('#login-password').type(password)
  cy.get('#login').submit()
}
// cypress/integration/spec.js
import { login } from './util'

it('logs in', () => {
  cy.visit('/login')
  login('username', 'password')
})

Проблемы Page Objects

В следующих разделах я рассмотрю конкретные примеры, где паттерн PageObject не соответствует тому, что нам нужно для написания хороших сквозных тестов.

  • Объекты PageObject трудно поддерживать, и это отнимает время у разработки приложения. Я никогда не видел PageObjects достаточно хорошо документированными, чтобы реально помочь в написании тестов.

  • PageObject вносит в тесты дополнительное значение, которое отделено от внутреннего значения приложения. Это затрудняет понимание тестов и приводит к сбоям..

  • PageObject пытаются вписать несколько объектов в единый интерфейс, возвращаясь к условной логике — огромный, на наш взгляд, антипаттерн.

  • PageObject делают тесты медленными, так как заставляют тесты всегда проходить через пользовательский интерфейс приложения.

Не отчаивайтесь! Я также покажу альтернативу Page Objects, которые я называю "App Actions", которую могут использовать наши сквозные тесты. Я считаю, App Actions очень хорошо решают вышеперечисленные проблемы, делая сквозные тесты быстрыми и продуктивными.

Пример добавления объектов

Возьмем в качестве примера тесты TodoMVC. Сначала проверим, может ли пользователь вводить todos. Мы будем использовать Cypress для этого через пользовательский интерфейс — точно так же, как и реальный пользователь будет вводить элементы.

describe('TodoMVC', function () {
  // set up these constants to match what TodoMVC does
  let TODO_ITEM_ONE = 'buy some cheese'
  let TODO_ITEM_TWO = 'feed the cat'
  let TODO_ITEM_THREE = 'book a doctors appointment'

  beforeEach(function () {
    cy.visit('/')
  })

  context('New Todo', function () {
    it('should allow me to add todo items', function () {
      cy.get('.new-todo').type(TODO_ITEM_ONE).type('{enter}')
      cy.get('.todo-list li').eq(0).find('label').should('contain', TODO_ITEM_ONE)
      cy.get('.new-todo').type(TODO_ITEM_TWO).type('{enter}')
      cy.get('.todo-list li').eq(1).find('label').should('contain', TODO_ITEM_TWO)
    })

    // more tests for adding items
    // - adds items
    // - should clear text input field when an item is added
    // - should append new items to the bottom of the list
    // - should trim text input
    // - should show #main and #footer when items added
  })
})

Все эти тесты внутри блока “New Todo” вводят элементы, добавляя <input class="new-todo" /> без shortcuts. Вот здесь тесты, которые сами запускаются.

Каждый тест начинается с текста “buy some cheese” (купить немного сыра), и нескольких других элементов, как если бы наш пользователь действительно любил сыр.

Завершая данный пример

Теперь давайте протестируем функцию "маркировка всех элементов как завершенных" (“Mark all as completed”). Пользователь может нажать на элемент в нашем приложении с разметкой, чтобы пометить все текущие завершенные элементы.

<input
  className='toggle-all'
  type='checkbox'
  onChange={this.toggleAll}
  checked={activeTodoCount === 0} />

Итак вопрос на миллион долларов — как включить todo элементы до того как кликать на .toggle-all? Мы можем написать и использовать пользовательскую команду такую как cy.createDefaultTodos().as('todos'), чтобы посмотреть страницу UI интерфейса, по сути, манипулируя страницей для создания элементов.

// cypress/support/commands.js
const TODO_ITEM_ONE = 'buy some cheese'
const TODO_ITEM_TWO = 'feed the cat'
const TODO_ITEM_THREE = 'book a doctors appointment'

Cypress.Commands.add('createDefaultTodos', function () {
  cy.get('.new-todo')
    .type(`${TODO_ITEM_ONE}{enter}`)
    .type(`${TODO_ITEM_TWO}{enter}`)
    .type(`${TODO_ITEM_THREE}{enter}`)
    .get('.todo-list li')
})

Мы создадим эту новую пользовательскую команду createDefaultTodos до проведения тестов в блоке.

// cypress/integration/spec.js
context('Mark all as completed', function () {
  beforeEach(function () {
    cy.createDefaultTodos().as('todos')
  })

  it('should allow me to mark all items as completed', function () {
    // complete all todos
    // we use 'check' instead of 'click'
    // because that indicates our intention much clearer
    cy.get('.toggle-all').check()

    // get each todo li and ensure its class is 'completed'
    cy.get('@todos').eq(0).should('have.class', 'completed')
    cy.get('@todos').eq(1).should('have.class', 'completed')
    cy.get('@todos').eq(2).should('have.class', 'completed')
  })

  // more tests
  // - should allow me to clear the complete state of all items
  // - complete all checkbox should update state when items are completed / cleared
})

Вот первый тест

Но учтите две вещи:

  1. Мы всегда включаем элементы через UI — повторяя то, что делал каждый тест в контексте "New Todo".

  2. Большая часть времени работы теста занята внесением элементов, а не проверкой элемента.

Последний момент важен — наши тесты медленные из-за внесения трех элементов через пользовательский интерфейс перед каждым тестом. Три теста в приведенном выше контексте "Отметить все как выполнено" (“Mark all as completed”) обычно занимают от 4 до 5 секунд.

App Actions

Исходный код

Окончательный исходный код этой записи блога вы можете найти в Application Actions. Вы также можете увидеть одни и те же тесты, реализованные в разных стилях, включая Page Object и App Actions в repo bahmutov/test-todomvc-using-app-actions.

Представьте себе, что вместо того, чтобы постоянно вносить новые элементы через пользовательский интерфейс, мы можем установить приложение непосредственно из нашего теста. Так как архитектура Cypress позволяет взаимодействовать с тестируемым приложением, это просто. Все, что нам нужно сделать, это добавить ссылочную переменную к объекту модели приложения, прикрепив ее, например, к объекту window.

// app.jsx code
var model = new app.TodoModel('react-todos');

if (window.Cypress) {
  window.model = model
}

Если мы добавляем ссылочную переменную model как объект приложения window это обеспечивает нашим тестам возможность вызвать метод model.addTodo, который уже есть в js/todoModel.js.

// js/todoModel.js
// Model: keeps all todos and has methods to act on them
app.TodoModel = function (key) {
  this.key = key
  this.todos = Utils.store(key)
  this.onChanges = []
}
app.TodoModel.prototype.addTodo = function (title) {
  this.todos = this.todos.concat({
    id: Utils.uuid(),
    title: title,
    completed: false
  });

  this.inform();
};
app.TodoModel.prototype.inform = ...
app.TodoModel.prototype.toggleAll = ...
// other methods

Вместо того, чтобы использовать пользовательскую команду Page Object, для создания todos таких как cy.createDefaultTodos().as('todos') мы можем использовать model.addTodo, чтобы сразу добавить объекты, используя внутренний “api” приложения. В коде ниже я использую cy.window() , чтобы открыть окно приложения, затем свойство model и затем .invoke() , чтобы вызвать метод addTodo на примере модели.

beforeEach(function () {
  cy.window().its('model').invoke('addTodo', TODO_ITEM_ONE)
  cy.window().its('model').invoke('addTodo', TODO_ITEM_TWO)
  cy.window().its('model').invoke('addTodo', TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
})

При приведенной выше схеме наши тесты проходят намного быстрее — все три заканчиваются чуть более чем за 1 секунду, уже в 3 раза быстрее, чем раньше. Но даже вышеприведенный код работает медленнее, чем необходимо — потому что мы используем несколько команд Cypress для добавления каждого элемента, что приводит к дополнительному времени. Вместо этого мы можем изменить TodoModel.prototype.addTodo, чтобы допускать несколько элементов одновременно.

// js/todoModel.js
app.TodoModel.prototype.addTodo = function (...titles) {
  titles.forEach(title => {
    this.todos = this.todos.concat({
      id: Utils.uuid(),
      title: title,
      completed: false
    });
  })

  this.inform();
};
// cypress/integration/spec.js
beforeEach(function () {
  cy.window().its('model').invoke('addTodo',
    TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
})

Вы заметили, что наш тест стал лучше? Мы НЕ изменили код теста, вместо этого мы улучшили код приложения. Используя внутренние возможности приложения из наших сквозных тестов, мы обязаны сделать приложение лучше во время написания наших тестов! Наши усилия непосредственно приводят к прояснению интерфейса модели, делая ее более тестируемой, лучше документированной и делая ее более простой в использовании из другого кода приложения.

Вы также можете запускать App Actions из консоли DevTools напрямую, переключая контекст в "Your app", см. снимок экрана ниже.

Просто функции

Мы можем перенести логику app actions в пользовательские команды, заменив использование пользовательского интерфейса для манипулирования положением внутреннего интерфейса модели приложения. Но я предпочитаю создавать небольшие функции многократного использования, а не прикреплять дополнительные методы к объекту cy .

const addDefaultTodos = () => {
  cy.window().its('model').invoke('addTodo',
    TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
}

beforeEach(addDefaultTodos)

Так как Cypress включает в себя бандлер, мы можем переместить addDefaultTodos в отдельный файл с утилитами и использовать require или import директивы, чтобы пользоваться ими в spec-файле. А также мы можем документировать addDefaultTodos, используя соглашение JSDoc, чтобы получить красивое продуманное завершение кода в наших тестовых файлах.

// utils.js
const TODO_ITEM_ONE = 'buy some cheese'
const TODO_ITEM_TWO = 'feed the cat'
const TODO_ITEM_THREE = 'book a doctors appointment'

/**
 * Creates default todo items using application action.
 * @example
 *  import { addDefaultTodos } from './utils'
 *  beforeEach(addDefaultTodos)
 */
export const addDefaultTodos = () => {
  cy.window().its('model').invoke('addTodo',
    TODO_ITEM_ONE, TODO_ITEM_TWO, TODO_ITEM_THREE)
  cy.get('.todo-list li').as('todos')
}

Использование app actions — это просто использование JavaScript-функций, а использование функций — просто.

Пример постоянства

Есть еще один пример в тестах TodoMVC, который показывает возможности задания начальных значений и действий. Тест на постоянство добавляет два элемента, нажимает на один из них, затем перезагружает страницу. Два элемента должны быть там и завершенное значение должно быть сохранено. Оригинальный тест на Cypress делает все через пользовательский интерфейс.

context('Persistence', function () {
  it('should persist its data', function () {
    // mimicking TodoMVC tests
    // by writing out this function
    function testState () {
      cy.get('@firstTodo').should('contain', TODO_ITEM_ONE)
        .and('have.class', 'completed')
      cy.get('@secondTodo').should('contain', TODO_ITEM_TWO)
        .and('not.have.class', 'completed')
    }

    cy.createTodo(TODO_ITEM_ONE).as('firstTodo')
    cy.createTodo(TODO_ITEM_TWO).as('secondTodo')
    cy.get('@firstTodo').find('.toggle').check()
    .then(testState)

    .reload()
    .then(testState)
  })
})

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

Но зачем мы вообще создаем элементы, и зачем нажимаем на первые, чтобы отметить их выполнение? Мы знаем, что это работает! У нас есть еще один тест, который уже тестировал пользовательский интерфейс для завершения элемента. Этот тест назывался Item — should allow me to mark items as complete, и он выглядит почти в точности так же:

context('Item', function () {
  it('should allow me to mark items as complete', function () {
    cy.createTodo(TODO_ITEM_ONE).as('firstTodo')
    cy.createTodo(TODO_ITEM_TWO).as('secondTodo')

    cy.get('@firstTodo').find('.toggle').check()
    cy.get('@firstTodo').should('have.class', 'completed')

    cy.get('@secondTodo').should('not.have.class', 'completed')
    cy.get('@secondTodo').find('.toggle').check()

    cy.get('@firstTodo').should('have.class', 'completed')
    cy.get('@secondTodo').should('have.class', 'completed')
  })
})

Мы НЕ должны повторять тесты для одних и тех же действий с пользовательским интерфейсом. Мы НЕ должны повторять взаимодействия с пользовательским интерфейсом, даже если мы следуем лучшим практикам и тестируем функции, а не реализацию с помощью тестовых идентификаторов и хорошей библиотеки помощника, такой как cypress-testing-library - все равно привязывает наши тесты к структуре страницы, и это может измениться.

Вот наша исходная операция с приложением с использованием пользовательского интерфейса.

cy.createTodo(TODO_ITEM_ONE).as('firstTodo')
cy.createTodo(TODO_ITEM_TWO).as('secondTodo')
cy.get('@firstTodo').find('.toggle').check()

А сейчас мы показываем то, как мы можем переделать функцию так, чтобы использовать app actions. Сначала мы используем addTodo для контроля приложения, и все еще используем флажок class="toggle", чтобы обозначить первый объект, как «завершенный».

// spec.js
import { addTodos } from './utils';

addTodos(TODO_ITEM_ONE, TODO_ITEM_TWO)
cy.get('.todo-list li').eq(0).find('.toggle').check()

Далее мы можем посмотреть на функции модели в методах todoModel.js, чтобы увидеть как мы можем сразу показать объект todo.

app.TodoModel.prototype.toggle = function (todoToToggle) {
  this.todos = this.todos.map(function (todo) {
    return todo !== todoToToggle ?
      todo :
      Utils.extend({}, todo, {completed: !todo.completed});
  });

  this.inform();
};

Можем ли мы использовать метод model.toggle, чтобы обозначить флаг completed? Cypress может все что угодно, что доступно в DevTools. Итак, еще раз, мы открываем DevTools из test runner, переключаемся на “Your App” и пробуем. Заметьте, как после завершения теста, я вызвал model.toggle(model.todos[0]) и первый объект в приложении вновь стал «незавершенным».

Напишем функцию утилитов для вызова app actions с помощью toggle. Для наших тестов мы, наверное, хотим запускать элемент не по ссылке переменной, а по индексу.

/**
 * Toggle given todo item. Returns chain so you can attach more Cypress commands
 * @param {number} k index of the todo item to toggle, 0 - first item
 * @example
 import { addTodos, toggle } from './utils'
 it('completes an item', () => {
   addTodos('first')
   toggle(0)
 })
 */
export const toggle = (k = 0) =>
  cy.window().its('model')
  .then(model => {
    expect(k, 'check item index').to.be.lessThan(model.todos.length)
    model.toggle(model.todos[k])
  })

В качестве альтернативы мы могли бы изменить функцию toggle в модели приложения, чтобы взять индекс в качестве аргумента. Видите, как тестовый код теперь является "клиентом" кода приложения и может повлиять на архитектуру и дизайн приложения?

Наш измененный тест создает элементы и запускает первый, который работает быстро.

context('Persistence', function () {
  // mimicking TodoMVC tests
  // by writing out this function
  function testState () {
    cy.get('.todo-list li').eq(0)
      .should('contain', TODO_ITEM_ONE).and('have.class', 'completed')
    cy.get('.todo-list li').eq(1)
      .should('contain', TODO_ITEM_TWO).and('not.have.class', 'completed')
  }

  it('should persist its data', function () {
    addTodos(TODO_ITEM_ONE, TODO_ITEM_TWO)
    toggle(0)
    .then(testState)

    .reload()
    .then(testState)
  })
})

Теперь я могу пройти через другие тесты и заменить каждый cy.get('.todo-list li').eq(k).find('.toggle').check() с toggle(k). Быстрее и надежнее.

Аналогичным образом, мы можем обновить маршруты сквозных тестов, чтобы НЕ проходить через элементы пользовательского интерфейса при настройке страницы, вместо этого используя app actions. В то же время мы кликаем по реальным ссылкам, которые мы тестируем как есть — и тест показывает, что ссылка на пользовательский интерфейс с текстом "Active" работает!

context('Routing', function () {
  beforeEach(addDefaultTodos) // app action

  it('should allow me to display active items', function () {
    toggle(1) // app action
    // the UI feature we are actually testing - the "Active" link
    cy.get('.filters').contains('Active').click()
    cy.get('@todos').eq(0).should('contain', TODO_ITEM_ONE)
    cy.get('@todos').eq(1).should('contain', TODO_ITEM_THREE)
  })
  // more tests
})

Обратите внимание, что всякий раз, когда вы заканчиваете писать чуть более длинный utility тест, например, toggle, это хороший индикатор того, что, возможно, потребуется изменить внутренний интерфейс приложения, вместо того, чтобы писать больше тестовых кодов!

// hmm, maybe we need to add a `model.toggleIndex()` method?
export const toggle = (k = 0) =>
  cy.window().its('model')
    .then(model => {
      expect(k, 'check item index').to.be.lessThan(model.todos.length)
      model.toggle(model.todos[k])
    })

Если вы добавите метод model.toggleIndex в приложение, тогда приложение , будет легче тестироваться, и возможно будет лучше работать в будущем. Тестовый код тоже будет упрощен.

DRY кодовый тест

Каждый блок тестов завершает тестирование. Мы можем использовать это в наших интересах с помощью app actions. Селекторы элементов, переданные в тестовый блок, будут локализованы в этом блоке. Это естественным образом сохраняет селекторы локализованными при каждом завершении тестирования. Все следующие тестовые блоки могут использовать app actions и не нуждаются в информации о селекторе. В примере ниже вы можете посмотреть на селекторы NEWTODO и TOGGLEALL.

describe('TodoMVC', function () {
  // testing item input
  context('New Todo', function () {
    // selector to enter new todo item is private to these tests
    const NEW_TODO = '.new-todo'

    it('should allow me to add todo items', function () {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      // more commands
    })
    // more tests that use NEW_TODO selector
  })

  // testing toggling all items
  context('Mark all as completed', function () {
    // selector to toggle all items is private to these tests
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function () {
      cy.get(TOGGLE_ALL).check()
      // more commands
    })
    // more tests that use TOGGLE_ALL selector
  })
})

Тесты выше показывают что каждый селектор приватен в определенном тестовом блоке. Например, селектор const NEWTODO = '.new-todo' приватный в тестовом блоке "New Todo", а также селектор const TOGGLEALL = '.toggle-all' в тестовом блоке "Mark all as completed". Для других тестов не нужно определять селекторы элементов страницы, чтобы добавить какие-либо элементы или пометить все как «выполненное» — вместо этого тесты могут использовать app actions .

Но в некоторых ситуациях вы можете захотеть поделиться селектором. Например, многие тесты из нескольких блоков могут понадобиться, чтобы собрать все элементы Todo на странице, и от этого никуда не деться. Мы все еще можем держать селектор в тестах без создания page objects таких как локальные переменные ALL_ITEMS.

describe('TodoMVC', function () {
  // common selector used across many tests
  const ALL_ITEMS = '.todo-list li'

  context('New Todo', function () {
    const NEW_TODO = '.new-todo'

    it('should allow me to add todo items', function () {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      cy.get(ALL_ITEMS)
        .eq(0)
        .find('label')
        .should('contain', TODO_ITEM_ONE)
    })
    // more tests
  })

  context('Mark all as completed', function () {
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function () {
      cy.get(TOGGLE_ALL).check()
      cy.get(ALL_ITEMS)
        .eq(0)
        .should('have.class', 'completed')
    })
    // more tests
  })
})

В примерах выше мы используем селектор const ALL_ITEMS = '.todo-list li' в различных тестах. Я даже предпочитаю создавать локальную utility функцию allItems, чтобы вернуть все элементы списка, а не разделять константу селектора.

describe('TodoMVC', function () {
  const ALL_ITEMS = '.todo-list li'

  /**
   * Returns all todo items
   */
  const allItems = () => cy.get(ALL_ITEMS)

  context('New Todo', function () {
    const NEW_TODO = '.new-todo'
    it('should allow me to add todo items', function () {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      allItems()
        .eq(0)
        .find('label')
        .should('contain', TODO_ITEM_ONE)
    })
    // more tests
  })

  context('Mark all as completed', function () {
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function () {
      cy.get(TOGGLE_ALL).check()
      allItems()
        .eq(0)
        .should('have.class', 'completed')
    })
    // more tests
  })
})

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

В этом случае мы можем переместить utility функцию allItems и селектор ALL_ITEMS в общий файл утилита и импортировать allItems из всех specs файлов, которые нам нужны.

// cypress/integration/utils.js
const ALL_ITEMS = '.todo-list li'

/**
 * Returns all todo items
 * @example
    import {allItems} from './utils'
    allItems().should('not.exist')
 */
export const allItems = () => cy.get(ALL_ITEMS)

// cypress/integration/spec.js
import { allItems } from './utils'

describe('TodoMVC', function () {
  context('New Todo', function () {
    const NEW_TODO = '.new-todo'

    it('should allow me to add todo items', function () {
      cy.get(NEW_TODO)
        .type(TODO_ITEM_ONE)
        .type('{enter}')
      allItems()
        .eq(0)
        .find('label')
        .should('contain', TODO_ITEM_ONE)
    })
    // more tests
  })

  context('Mark all as completed', function () {
    const TOGGLE_ALL = '.toggle-all'

    beforeEach(addDefaultTodos)

    it('should allow me to mark all items as completed', function () {
      cy.get(TOGGLE_ALL).check()
      allItems()
        .eq(0)
        .should('have.class', 'completed')
    })
    // more tests
  })
})

Цель состоит в том, чтобы сделать тесты легко читаемыми, простыми для понимания и безопасными для каких-либо изменений при необходимости.

Целенаправленные ошибки

Одним из прекрасных достоинств использования app actions для проведения тестов являются целенаправленные ошибки. Например, в пользовательском интерфейсе приложения установлен флажок для переключения всех элементов по мере их выполнения. Код выглядит так:

if (todos.length) {
  main = (
    <section className='main'>
      <input
        className='toggle-all'
        type='checkbox'
        onChange={this.toggleAll}
        checked={activeTodoCount === 0}
      />
      <ul className='todo-list'>{todoItems}</ul>
    </section>
  )
}

Если я удаляю элемент <input className='toggle-all' … />, только тесты внутри блока «Отметить как выполнено» (“Mark all as completed”) прерываются.

Никакой другой тест не проходит через этот элемент пользовательского интерфейса, поэтому никакие другие тесты не прерываются.

Аналогично, каждый элемент Todo выводит флажок для обозначения своего собственного завершенного элемента. Код выглядит так.

<input
  className="toggle"
  type="checkbox"
  checked={this.props.todo.completed}
  onChange={this.props.onToggle}
/>

Если я уберу onChange={this.props.onToggle} :

<input
  className='toggle'
  type='checkbox'
  checked={this.props.todo.completed}
  // onChange={this.props.onToggle}
/>

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

Я очень рад, что функция одной страницы влияет только на один набор тестов. Это долгожданное изменение по сравнению с типичным «каким-то маленьким UI изменением — теперь половина сквозных тестов красного цвета».

Если мы используем TypeScript для написания нашего приложения, наши тесты могут даже использовать определение интерфейса модели приложения для корректного вызова существующих методов. Это ускорит рефакторинг, потому что определения типов позволят сразу же рефакторинговать все места, где тестовый код вызывается в коде приложения.

Ограничения App Actions

Слишком много действий слишком быстро

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

// model
app.TodoModel.prototype.addTodo = function (...todos) {
  // make XHR to the server to save todos
  ajax({
    method: 'POST',
    url: '/todos',
    data: todos
  }).then(() =>
    then update local state
    this.saveTodos(todos)
  ).then(() =>
    // this triggers DOM render
    this.inform()
  )
}
// spec.js
it('completes all items', () => {
  addDefaultTodos()
  toggle(1) // marks item completed
  // click on "Completed" link
  // assert there is 1 completed item
})

Вышеуказанный тест completes all items, который завершает все элементы, скорее всего, иногда пройдет, а иногда и не пройдет. И все потому, что тест работает быстрее, чем приложение может обрабатывать действия.

Например, в то время как приложение все еще добавляет новые todos внутри метода addTodo, тест уже посылает toggle action, который будет пытаться завершить элемент todo с индексом 1. Может быть, когда приложению дано достаточно времени, чтобы отправить оригинальный список todo на сервер и установить их в локальном состоянии — в этом случае тест пройдет. Но большую часть времени приложение все еще ждет ответа от сервера — в этом случае локальный список элементов все еще пуст и попытка запустить элемент с индексом 1 спровоцирует ошибку.

Используя действия приложения для управления приложением, мы отошли от того, как пользователь будет использовать наше приложение. Пользователь не сможет запустить элемент до того, как он будет показан пользователю на странице. Таким образом, нашим тестам необходимо дождаться появления элементов в пользовательском интерфейсе, прежде чем запускать toggle(1). Опять же простой функции многократного использования должно быть достаточно.

it('completes all items', () => {
  addDefaultTodos()
  allItems().should('have.length', 3)
  toggle(1) // marks item completed
  // click on "Completed" link
  // assert there is 1 completed item
})

Я настоятельно рекомендую схему, показанную выше — то есть выполнить app action и дождаться обновления пользовательского интерфейса до желаемого состояния путем написания утверждения, затем выполнить другой app action, и снова дождаться обновления пользовательского интерфейса. Это выполняется как можно быстрее, потому что Cypress может непосредственно наблюдать за DOM и продолжать следующее действие, как только утверждения пройдут.

Вы не ограничены возможностью наблюдения за DOM — вы можете так же легко шпионить за сетевыми звонками. Например, мы можем шпионить за POST /todos XHR, который вызывается от приложения к серверу и ожидает сетевого вызова, прежде чем выполнить действие toggle(1).

it('completes all items', () => {
  cy.server()
  cy.route('POST', '/todos').as('save')
  addDefaultTodos()
  cy.wait('@save') // waits for XHR POST /todos before test continues
  toggle(1) // marks item completed
  // click on "Completed" link
  // assert there is 1 completed item
})

Еще лучше — мы можем шпионить за методами непосредственно в нашем приложении! Так как наше приложение вызывает model.inform, когда будет сделано обновление статуса, это хороший знак, что мы можем вызвать другой app action.

it('completes all items', () => {
  cy.window()
    .its('model')
    .then(model => {
      cy.spy(model, 'inform').as('inform')
    })
  addDefaultTodos()
  // wait until the spy is called once
  cy.get('@inform').should('have.been.calledOnce')
  toggle(1) // marks item completed
  // click on "Completed" link
  // assert there is 1 completed item
})

Интерфейс Cypress UI показывает информацию о каждом методе, за которым мы следим, в журнале команд.

Подводя итог: app actions могут быть вызваны из теста быстрее, чем приложение может их обработать. В этом случае, вы можете интерпретировать тесты как ненадёжные из-за задержки между тестом и приложением. К счастью, вы можете синхронизировать тест и приложение несколькими способами. Тест может:

  1. Дождаться обновления DOM, как и ожидалось.

  2. Наблюдать за сетевым трафиком и ждать ожидаемого вызова XHR.

  3. Шпионить за методом в приложении и продолжать работу, когда оно будет вызвано.

Когда какие-либо действия ограничены

Иногда код приложения не получает желаемого результата. Например, в обсуждении Cypress Best Practices Brian Mann заявил:

  • При тестировании страницы входа в систему, сквозные тесты должны использовать пользовательский интерфейс так же, как это делает пользователь

  • При тестировании любого другого пользовательского потока, требующего входа в систему, тест должен сразу выполнить вход (например, с помощью команды cy.request()), и не проходить через пользовательский интерфейс снова и снова.

В вышеприведенной реализации код приложения не может выполнить вход в систему тем же методом, что и cy.request. Таким образом, сквозные тесты должны вызывать cy.request(), а не вызывать app actions. Это позволяет избежать использования шаблона объекта страницы — и для этого достаточно выполнить пользовательскую команду или простую функцию.

Финальные мысли

Переход от Page Objects, которые всегда проходят через пользовательский интерфейс страницы, к App Actions, которые контролируют приложение через его внутренний API модели, приносит много преимуществ.

  • Тесты становятся намного быстрее. Даже простые тесты TodoMVC, запущенные локально в сравнении с браузером Cypress Electron, выполняются не за 34 секунд, а за 17 секунд после перехода от прохождения через пользовательский интерфейс к использованию App Actions — то есть скорость увеличивается на 50%.

  • Теперь тесты влияют на код приложения и выигрывают от его рефакторинга. Чем разумнее и лучше документирован внутренний интерфейс приложения, тем проще будет написать для них сквозные тесты.

  • Лучше избегать написания слабо связанного отдельного слоя кода для сложных и нестабильных пользовательских интерфейсов. Вместо этого тесты используются и выполняются для более длительного использования внутреннего интерфейса модели приложения.

На самом деле, функции утилита, которые мне приходилось писать лишь отображают синтаксис теста для App Actions а большинство из них просто не имеют синтаксиса.

export const addTodos = (...todos) => {
  cy.window().its('model').invoke('addTodo', ...todos)
}

Нет параллельного состояния (внутри page objects), нет логики условного тестирования — просто прямая ссылка на код приложения, как это можно сделать из консоли DevTools.

Больше информации

Больше информации по этой статье в Application Actions. Также смотрите выполнение таких тестов в других стилях, включая Page Object и App Actions в repo bahmutov/test-todomvc-using-app-actions.

Вы можете использовать ту же идею App Actions, чтобы вести контроль ваших тестов. В предыдущем блоге я уже говорил о:

Приведенные выше примеры и посты в блоге помогли мне увидеть недостатки Page Objects, а также преимущества App Actions в сквозных тестах.


Узнать подробнее о курсе «JavaScript QA Engineer».

Принять участие в открытом вебинаре на тему «Что нужно знать о JS тестировщику».

Теги:javascriptpage objectавтоматизация тестирования
Хабы: OTUS Тестирование IT-систем JavaScript
Всего голосов 9: ↑8 и ↓1 +7
Просмотры4.1K

Похожие публикации

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Россия
Сайт
otus.ru
Численность
51–100 человек
Дата регистрации
Представитель
OTUS

Блог на Хабре