Cypress Journey To Page Objects And Back

How I write page objects in my Cypress tests when I have to.

🎁 I wrote this blog post following recording 4 short videos showing how I prefer to write tests that need page objects. Find the videos and the source code in the repo bahmutov/todomvc-callback-example or via my YouTube channel.

Adding todos test

Let's take TodoMVC application. You are testing the most important part of the application: adding todos. The test starts simple, it uses the user interface to confirm the new item appears in the list. The test resets the data before adding its item.

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
it('creates a todo item', () => {
cy.request('POST', '/reset', { todos: [] })
cy.visit('/')
cy.get('.loaded')
cy.get('.new-todo').type('write code{enter}')
cy.get('.todo-list li')
.should('have.length', 1)
.first()
.should('have.attr', 'data-todo-id')
})

Each new item gets its own unique id, and this unique attribute is exposed in the HTML as data-todo-id string.

The data-todo-id attribute on the element

📺 You can watch me using test-driven development when adding the data-todo-id attribute in the video TDD Example: Add data- Attribute.

Great, but once we are writing more tests (completing todos, deleting items, item filters), we would need to do the same steps from other specs. I see roughly 4 reusable steps in the test above:

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
it('creates a todo item', () => {
cy.request('POST', '/reset', { todos: [] }) // 1: data reset
cy.visit('/') // 2: page visit
cy.get('.loaded') // 2: page visit
cy.get('.new-todo').type('write code{enter}') // 3: adding a new todo
cy.get('.todo-list li') // 4: querying the page
.should('have.length', 1) // logic unique to this test
.first()
.should('have.attr', 'data-todo-id')
})

Page object

If we know that we will reuse certain testing commands from different specs, we can move this logic into a page object utility. Here is my

cypress/e2e/todomvc.page.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const TodoMVCPage = {
reset() {
cy.request('POST', '/reset', { todos: [] })
},

visit() {
cy.visit('/')
cy.get('.loaded')
},

/**
* Adds a new todo item
* @param {string} title
*/
addTodo(title) {
cy.get('.new-todo').type(`${title}{enter}`)
},

getTodos() {
return cy.get('.todo-list li')
},
}

A couple of points about the page objects the way I write them:

  • page objects are named exports
  • they are simple static objects without any internal logic or data encapsulation
  • I use either TypeScript or JSDoc comments to describe the methods and provide type checking

The test then simply calls the page object methods before checking the item

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
import { TodoMVCPage } from './todomvc.page'

it('creates a todo item', () => {
TodoMVCPage.reset()
TodoMVCPage.visit()
TodoMVCPage.addTodo('write code')
TodoMVCPage.getTodos()
.should('have.length', 1)
.first()
.should('have.attr', 'data-todo-id')
})

You can watch the refactoring in the video below

Get the created item's id

Our test simply checks if the item element has the data attribute. It does not check its value, since we do not know the new random test id. Let's verify the attribute by passing the item's id back to the testing code. We know the application is creating the id in its frontend code and then sends it to the backend.

The backend responds with the same item, so we could grab it from the network call

The item id can be grabbed from the network call

1
2
3
4
5
6
7
8
addTodo(title) {
cy.intercept('POST', '/todos').as('newTodo')
cy.get('.new-todo').type(`${title}{enter}`)
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
// how to pass the id back to the caller test?
}

We can easily grab the id value from the intercepted network call, but how do we pass it back to the test? Cypress code is declarative and asynchronous, but it does not use promises, since promises are limited to be a single execution. Thus you do not return a value from Cypress commands.

1
2
// 🚨 DOES NOT WORK
const id = TodoMVCPage.addTodo('write code')

Instead, we need to pass the value we get from the application forward.

Using a callback

One approach people use in Cypress tests mimics the asynchronous Node.js programming from about 10 years ago: using callbacks. I have a few blog posts about Node callbacks, for example Put callback first for elegance. Our test could pass a callback with more testing commands:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// the page object code

/**
* Adds a new todo item
* @param {string} title
* @param {Function?} callback
*/
addTodo(title, callback) {
cy.intercept('POST', '/todos').as('newTodo')
cy.get('.new-todo').type(`${title}{enter}`)
if (callback) {
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.then(callback)
} else {
cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
}
},

// the spec code
it('creates a todo item', () => {
TodoPage.reset()
TodoPage.visit()
TodoPage.addTodo('write code', (id) => {
TodoPage.getTodos()
.should('have.length', 1)
.first()
.should('have.attr', 'data-todo-id', id)
})
})

Tip: I like adding sanity assertions like .should('be.a', 'string') to catch obvious errors plus to print the value in the Cypress Command Log.

Passing the item id via callback back to the test

Ok, not bad, but using a lot of callbacks quickly becomes hard to code and hard to debug.

From callbacks to Cypress chain

Node.js has migrated from using callbacks to attaching these callbacks to a Promise instance.

1
2
3
4
// using callbacks
myFunction(myCallback)
// using promises
myFunction.then(myCallback)

We can do the same by returning a Cypress chain object (which is returned by every Cypress command). Our page object method simply returns the last command, including all its assertions

1
2
3
4
5
6
7
8
// the page object code
addTodo(title) {
cy.intercept('POST', '/todos').as('newTodo')
cy.get('.new-todo').type(`${title}{enter}`)
return cy.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
},

The spec instead of passing a callback, simply passes it to the Cypress cy.then command:

1
2
3
4
5
6
7
8
9
10
11
// the spec code
it('creates a todo item', () => {
TodoPage.reset()
TodoPage.visit()
TodoPage.addTodo('write code').then((id) => {
TodoPage.getTodos()
.should('have.length', 1)
.first()
.should('have.attr', 'data-todo-id', id)
})
})

Note: I always thought that cy.then command should have been called cy.later to avoid the name clash with the Promise syntax.

Using an alias

Returning a Cypress commands lets us easily work with the last subject value produced by the Cypress commands inside the page object method TodoPage.addTodo. But what if we want to produce several values? We can use Cypress aliases.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// the page object code

/**
* Adds a new todo item
* @param {string} title
* @param {string} idAlias - alias to use for the new todo item id
* @example
* TodoPage.addTodo('write code', 'newTodoId')
* cy.get('@newTodoId').should('be.a', 'string')
*/
addTodo(title, idAlias) {
cy.intercept('POST', '/todos').as('newTodo')
cy.get('.new-todo').type(`${title}{enter}`)
return cy
.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.as(idAlias)
}

The test code can simply decide what is the alias name to use.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// the spec code
it('creates a todo item', () => {
TodoPage.reset()
TodoPage.visit()
TodoPage.addTodo('write code', 'newTodoId')

cy.log('**checking the new todo item**')
cy.get('@newTodoId').then((id) => {
TodoPage.getTodos()
.should('have.length', 1)
.first()
.should('have.attr', 'data-todo-id', id)
})
})

Notice the separation between calling the page object and using the aliased value

1
2
3
4
5
6
TodoPage.addTodo('write code', 'newTodoId')

cy.log('**checking the new todo item**')
cy.get('@newTodoId').then((id) => {
...
})

You can watch the transformation from callbacks to Cypress aliases in the video below

Save an object under an alias

Saving just a single item's id under an alias might not be enough for our needs. Imagine that we cannot clear the backend's database before creating a single todo item. We need to create an item that does not clash with any previously created items. The item should be simple to find; it calls for a random title text.

We can form a random string using the included Lodash library method:

1
const title = `random todo ${Cypress._.random(1e4, 1e5)}`

But how do we return both the title and the item's id together? The easiest case is to form the object that we want to return and yield back from the page object method.

cypress/e2e/todo.page.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* Adds a new todo item
* @param {string} title
* @param {string} idAlias - alias to use for the new todo item id
* @example
* TodoPage.addTodo('write code', 'newTodoId')
* cy.get('@newTodoId').should('be.a', 'string')
*/
addTodo(title, idAlias = 'newTodoId') {
cy.intercept('POST', '/todos').as('newTodo')
cy.get('.new-todo').type(`${title}{enter}`)
return cy
.wait('@newTodo')
.its('response.body.id')
.should('be.a', 'string')
.as(idAlias)
},

/**
* Creates a random todo item using a random title.
* Yields an object with both the id and the title.
* @example
* TodoPage.addRandomTodo().then(({ title, id }) => { ... })
*/
addRandomTodo() {
const title = `random todo ${Cypress._.random(1e4, 1e5)}`
cy.log(`Adding todo item "${title}"`)
return this.addTodo(title).then((id) => {
return { title, id }
})
},

The page object method addTodo yields the id. We grab the title string and form the object to be returned from the cy.then callback

1
2
3
4
const title = `random todo ${Cypress._.random(1e4, 1e5)}`
return this.addTodo(title).then((id) => {
return { title, id }
})

Any defined value returned by the cy.then callback becomes the new subject. The spec code can destructure the object for example:

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
it('creates a random todo item', () => {
TodoPage.reset()
TodoPage.visit()
// create the random todo item
// and confirm the element's text and the data id attribute
TodoPage.addRandomTodo().then(({ title, id }) => {
cy.contains('li.todo', title).should(
'have.attr',
'data-todo-id',
id,
)
})
})

The second test uses a random todo title

If you want to watch this refactoring, check out the video below

Control the data

Finally, we can avoid the entire "async / await is missing in Cypress" (even if you could hack it yourself) problem. The problem comes from your test needing to "get" the data from the application because it does not "know" it. As my previous video Good Cypress Test Syntax showed: if you can control the test data and avoid non-deterministic values, then your test should know precisely the item's text and id. The test becomes really simple.

Our application is creating todo objects and assigning id using the following frontend code

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
function randomId() {
return Math.random().toString().substr(2, 10)
}

const todo = {
title: state.newTodo,
completed: false,
id: randomId(),
}
track('todo.add', todo.title)
axios.post('/todos', todo).then(() => {
commit('ADD_TODO', todo)
})

The code line return Math.random().toString().substr(2, 10) creates the unknown data. We can control it from the Cypress test though. Let's stub the global method Math.random from our test.

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('creates a todo item with non-random id', () => {
const title = `random todo ${Cypress._.random(1e4, 1e5)}`
TodoPage.visit()
// stub the random number generate
// so it always returns the same value
cy.window().then((win) => {
cy.stub(win.Math, 'random').returns(0.567)
})
TodoPage.addTodo(title)
// confirm the "id" attribute has the expected value "567"
cy.contains('li.todo', title).should(
'have.attr',
'data-todo-id',
'567',
)
})

The original Math.random function returns a floating point number between 0 and 1, like 0.237891... We stub this method to return the same number every time it is called. Since we are only creating a single todo item, this stub works just fine. The stub will return 0.567, which the randomId function converts into the id value 567. The test "knows" the id to expect, since it control it, and asserts it is present in the DOM.

The test controls the randomness in creating the id

No more randomness, the test knows precisely what to expect, and thus does not need to get anything from the application. No more cy.then and no more aliases. Simple, declarative programming. If you want to watch how I stub Math.random, watch the video below