Stop using Page Objects (PO) and start using App Actions

Hello, Khabrovites. For future students of the JavaScript QA Engineer course we prepared a translation of useful material.



We also invite everyone to take part in the
open webinar on the topic “What a tester needs to know about JS”. The lesson will consider the features of JS, which you need to keep in mind all the time when writing tests.






— . -, 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 :









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, . :





  • Dispatch Redux actions   





  • Dispatch Vuex actions DOM updates





Page Objects, App Actions .






«JavaScript QA Engineer».



« JS ».












All Articles