Faster User Object Creation

How to create and cache the user object and session using cypress-data-session plugin.

In the blog post Post not found: cypress-data-session I have introduced the cypress-data-session plugin. In this blog post I will show how to use this plugin to speed up creating a user during the test, and how to instantly log in the user by caching the session cookie.

🎁 You can find the source code for this post in the repo bahmutov/chat.io.

Utilities

First, let's introduce two utility functions for creating a new user and for logging in.

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
34
35
36
37
38
39
40
41
42
43
44
/**
* A function that visits the page and creates the new user by submitting a form.
* @param {string} username - the username to use
* @param {string} password - the password to use
*/
function registerUser(username, password) {
cy.visit('/')

cy.get('#create-account').should('be.visible').click()
cy.get('.register-form')
.should('be.visible')
.within(() => {
cy.get('[placeholder=username]')
.type(username, { delay: 100 })
.should('have.value', username)
cy.get('[placeholder=password]').type(password, { delay: 100 })

cy.contains('button', 'create').click().should('be.disabled')
})
// if everything goes well
cy.contains('.success', 'Your account has been created').should('be.visible')
}

/**
* Opens the page, enters the username and password and clicks the login button.
* If the login is successful, the browser should redirect to the rooms page.
* @param {string} username Existing user name
* @param {string} password The password to use
*/
function loginUser(username, password) {
cy.visit('/')

cy.get('.login-form')
.should('be.visible')
.within(() => {
cy.get('[placeholder=username]')
.type(username, { delay: 100 })
.should('have.value', username)
cy.get('[placeholder=password]').type(password, { delay: 100 })

cy.contains('button', 'login').click()
cy.location('pathname').should('equal', '/rooms')
})
}

Note that registerUser assumes the user with the given username does not exist yet. Now let's right our first test.

The first test

The first test assumes a clean slate.

1
2
3
4
5
6
7
8
9
it('registers and logs in via UI', () => {
const username = 'Test'
const password = 'MySecreT'
registerUser(username, password)
loginUser(username, password)
// check if the user is logged in successfully
cy.location('pathname').should('equal', '/rooms')
cy.contains('.user-info', 'Test').should('be.visible')
})

It works

The test passes on the first run

The test unfortunately fails when we try to re-run it with the error "Username already exists"

The test fails to create a user with the same username

Ok, so we need to delete the user, or clear all users, or use a random username for every test.

Deleting all users

We can update the test to delete the users before registration

1
2
3
4
5
6
7
8
9
10
it('deletes all users before registering', () => {
cy.task('clearUsers')
const username = 'Test'
const password = 'MySecreT'
registerUser(username, password)
loginUser(username, password)
// check if the user is logged in successfully
cy.location('pathname').should('equal', '/rooms')
cy.contains('.user-info', 'Test').should('be.visible')
})

The task clearUsers is registered in the cypress/plugins/index.js file and uses the application's database code to perform its task.

cypress/plugins/index.js
1
2
3
4
5
6
7
8
9
10
11
const database = require('../../app/database')
async function clearUsers() {
console.log('clear users')
await database.models.user.deleteMany({})
return null
}
module.exports = (on, config) => {
on('task', {
clearUsers,
})
}

🎓 You can clear the users yourself from the browser's DevTools console by calling the task:

1
cy.now('task', 'clearUsers')

Great, but now every test takes six seconds - and this is our little application. What if we needed to set something else? Or if there are external systems involved that slow the user object creation? Do we want to wait 30 seconds just to start each test?

Conditional creation

We probably want to create the user if it does not exist yet. Good, how do we do that? By using cypress-data-session - just move the registerUser command into the cy.dataSession callback setup.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
it('cache the created user', () => {
const username = 'Test'
const password = 'MySecreT'
cy.dataSession({
name: 'user',
setup() {
registerUser(username, password)
},
// as long as there is something in memory
// we know we have created the user already
validate: true,
})
loginUser(username, password)
// check if the user is logged in successfully
cy.location('pathname').should('equal', '/rooms')
cy.contains('.user-info', 'Test').should('be.visible')
})

The first time the test runs, there is no data session with the name "user", thus it executes the setup callback with our command to register the user.

The first time the test run

The test looks exactly like our very first attempt, but with an extra message at the top of cy.dataSession

The data session user needs to be created

Let's run the test again.

The second run

Notice the test became faster - it skipped creating the user steps completely. At the start of the data session, it found something in its memory, it was valid (thanks to the validate: true parameter!), and it skipped running registerUser function.

The data session user was found in memory

Super. We can even see what was cached in memory under the session name "user" by running from the DevTools console Cypress.getDataSession('user') - this static method is added to the global Cypress object by the cypress-data-session plugin.

The data session contents

The data session stores whatever is yielded by the last Cypress command inside the setup callback. In our case, it was the DOM element cy.contains('.success', 'Your account has been created'). It works in our case, in the future example we will store something more meaningful like the user session cookie.

Restarting the spec

What happens if we hard-reload the spec file? Or close and open the Cypress? We will have no data sessions, but the user is already in the database. This will break our test, as we will try to create the user to store in the data session, hitting the "Username already exists" error. This is where the init callback is used.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cy.dataSession({
name: 'user',
// if there is nothing in memory for the session
// try pulling the user from the database
init() {
cy.task('findUser', username)
},
setup() {
registerUser(username, password)
},
// as long as there is something in memory
// we know we have created the user already
validate: true,
})

The first time cy.dataSession runs and does not find the "user" session, it first runs the init callback. If it yields something, and that value passes the validation (thanks again to validate: true property), our code skips the setup and stores the value in memory. Now we have the data session ready to go, initialized with the already existing user.

Find the user in the database and cache it

The task findUser is implemented in the plugin file using the database access code, just like clearUsers is.

cypress/plugins/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const database = require('../../app/database')
async function findUser(username) {
console.log('find user', username)
if (typeof username !== 'string') {
throw new Error('username must be a string')
}
return database.models.user.findOne({ username })
}
module.exports = (on, config) => {
on('task', {
clearUsers,
findUser,
})
}

Session data session

What about logging in the user? Do we always have to go through the page and submit the form? How does the browser "know" that the user is logged in? Look at the DevTools - in our case, the /login form submission, if the user supplies valid username and password, the server sets the session cookie called connect.sid.

The session cookie

Ok, let's use cy.dataSession. If we have nothing in memory, our setup should do what we have done already - call the loginUser function. The browser will finish with the logged in user - and that's when we grab the cookie and cy.dataSession will store it in memory for us.

1
2
3
4
5
6
7
8
9
10
11
12
cy.dataSession({
name: 'user',
...
})
cy.dataSession({
name: 'logged in',
setup() {
loginUser(username, password)
cy.getCookie('connect.sid')
},
validate: true,
})

Great, but what about the second run? We have the cookie value stored in memory inside the data session "logged in", but the browser is on the blank page. How do we use the cookie? By setting it ourselves using the cy.setCookie before visiting the page. And we set the cookie and visit the page in the recreate callback - this function every time the cy.dataSession has valid item in memory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cy.dataSession({
name: 'user',
...
})
cy.dataSession({
name: 'logged in',
setup() {
loginUser(username, password)
cy.getCookie('connect.sid')
},
validate: true,
recreate(cookie) {
cy.setCookie('connect.sid', cookie.value)
cy.visit('/rooms')
},
})

Beautiful - and fast too. Look at the execution timing when we have the cookie already in memory, we completely skip the slow parts of the test.

Two data sessions make the test fast

We can inspect the cookie stored in the data session memory.

Dump the logged in data session value

Dependent data sessions

We have two data sessions: "user" and "logged in". We storing the user "object" which is not really an object, and the cookie. What if the user object is deleted from the database? Then the test can no longer log in using the cached cookie - because that cookie does not belong to a valid user any more, and the backend check will reject it. Thus the data session "logged in" depends on the data session "user". If the data session "user" is recomputed for whatever reason, the user needs to be logged in again. This can be done automatically by explicitly listing the dependency between the two data sessions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cy.dataSession({
name: 'user',
...
})
cy.dataSession({
name: 'logged in',
setup() {
loginUser(username, password)
cy.getCookie('connect.sid')
},
validate: true,
recreate(cookie) {
cy.setCookie('connect.sid', cookie.value)
cy.visit('/rooms')
},
dependsOn: ['user'],
})

Under the hood, the cy.dataSession command keeps the timestamp when the data session called setup function. Thus it can tell if the parent session was recomputed, which invalidates the current data session.

Validation

We skipped over the validation logic, instead using the validate: true in our test. Whenever there is something in the data session memory, we assumed it was valid. This is incorrect, we should validate the user object, and we should check if the session is still valid.

Here is how we validate the user object - we yield it from the init and setup and it will be stored

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
cy.dataSession({
name: 'user',
init() {
cy.task('findUser', username)
},
setup() {
registerUser(username, password)
cy.task('findUser', username)
},
validate(user) {
cy.task('findUser', user.username).then(
(found) => found && found._id === user._id,
)
},
})

We can see the object yielded by cy.task('findUser') stored in memory

The user object to be validated

Our validate callback will receive the object from memory, and it needs to make sure there is still a user with this username, and the IDs match. Then we know the user is still good to use.

Let's validate the session cookie to prevent the tests failing if the session is very short or there was some backend session purge. Let's use cy.request to verify the cookie - by trying to request a protected resource.

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
cy.dataSession({
name: 'logged in',
setup() {
loginUser(username, password)
cy.getCookie('connect.sid')
},
validate(cookie) {
// try making a request with the cookie value
// to a protected route. If it is successful
// we are good to go. If we get a redirect
// to login instead, we know the cookie is invalid
cy.request({
url: '/rooms',
failOnStatusCode: false,
followRedirect: false,
headers: {
cookie: `connect.sid=${cookie.value}`,
},
})
.its('status')
.then((status) => status === 200)
},
recreate(cookie) {
cy.setCookie('connect.sid', cookie.value)
cy.visit('/rooms')
},
dependsOn: ['user'],
})

Validating the cached cookie by making a request

Beautiful and fast.