Sharing authentication state
We know that the state must not be shared between subsequent tests because it's the reason #1 for brittle tests. But when you know the rules, you know the motivations behind them and how to bend them to your needs.
Citing again the testing rules: why should we not share the state between tests? Imagine this scenario:
test a
registers a new user and does actions that need the user to be registeredtest b
does actions that need the user to be registered
You are going to break the test b
test if
you run it alone with
it.only()
you skip the
test a
withit.skip()
you change the order
you move the
test b
to another file or vice-versa
summing up: every test must be independent.
In the previous chapter, we improved the test performance but it's still quite slow. Every test registers a new user and it would be cool if all the tests could share the same, already registered, user... But if we only have to maintain test independence, we could write a custom command that registers a new user, store the user token and restore the token before the next step!
One thing at a time:
- the RealWorld front-end stores the user token with a
jwt
local storage field
we could copy it after the first registration (it can be accessed in Cypress with
localStorage.jwt
)then, we can restore it for every future test (checking that the user is still valid and authenticated for the back-end)
Let's write the cy.signupV3
custom command, starting from the cy.signupV2
command that's the following:
File: cypress/support/signup/signup-v2.js
/// <reference types="Cypress" />
import { paths } from "../../../realworld/frontend/src/components/App";
import { noArticles } from "../../../realworld/frontend/src/components/ArticleList";
Cypress.Commands.add("signupV2", ({ email, username, password } = {}) => {
const random = Math.floor(Math.random() * 100000);
const user = {
username: username || `Tester${random}`,
email: email || `user+${random}@realworld.io`,
password: password || "mysupersecretpassword"
};
// set up AJAX call interception
cy.intercept("POST", "**/api/users").as("signup-request");
cy.visit(paths.register);
cy.window()
.its("appActions")
.invoke("signup", user);
// ... and AJAX call waiting
cy.wait("@signup-request").should(interception => {
expect(interception.request.body).deep.equal({
user: {
username: user.username,
email: user.email,
password: user.password
}
});
expect(interception.response.statusCode).to.equal(200);
cy.wrap(interception.response.body)
.should("have.property", "user")
.and(
user =>
expect(user)
.to.have.property("token")
.and.to.be.a("string").and.not.to.be.empty
)
.and("deep.include", {
username: user.username.toLowerCase(),
email: user.email
});
});
// end of the flow
cy.findByText(noArticles).should("be.visible");
cy.then(() => user);
});
cy.gignupV3
Storing the token
/// <reference types="Cypress" />
import { paths } from "../../../realworld/frontend/src/components/App";
import { noArticles } from "../../../realworld/frontend/src/components/ArticleList";
+let previousUserData = {};
Cypress.Commands.add("signupV2", ({ email, username, password } = {}) => {
// ... the command code...
cy
+ // wait until the localStorage token is saved
+ .wrap(localStorage)
+ .its("jwt")
+ .should(jwt => expect(jwt).to.be.a("string").and.not.to.be.empty)
+ .then(jwt => {
+ previousUserData = {
+ jwt,
+ username,
+ email,
+ password,
+ user
+ };
+ })
.then(() => user);
});
What the new code does:
waits until the
localStorage.jwt
token is available, the front-end does not set it synchronously (or, better, you should get accustomed that nothing is synchronous with E2E tests). This waiting is made easy by the inner Cypress retry-ability that retries the previous command (unless it could potentially change the state of the application under test like aclick
could do) until the assertions pass.
So, thejwt
retrieval (.its("jwt")
) is retried until the next assertion.should(jwt => expect(jwt).to.be.a("string").and.not.to.be.empty)
passes.once the
jwt
token is set, it stores all the user data (the token, the parameters, and the newuser
object)"returns" the
user
object with.then(() => user)
. It's an asynchronous flow so it "returns" the object by chaining it
Initial token check
/// <reference types="Cypress" />
import { paths } from "../../../realworld/frontend/src/components/App";
import { noArticles } from "../../../realworld/frontend/src/components/ArticleList";
let previousUserData = {};
-Cypress.Commands.add("signupV2", ({ email, username, password } = {}) => {
+Cypress.Commands.add("signupV3", ({ email, username, password, ignoreLocalStorage = false } = {}) => {
+ let user;
// if the user data match the previous one
+ if (!ignoreLocalStorage && previousUserData.jwt && previousUserData.email === email) {
+ cy.log("signupV3: Authentication check ⚠️");
// set the stored token
+ localStorage.setItem("jwt", previousUserData.jwt);
+ cy.visit("/")
// use the "New Post" string to detect if the user is authenticated or not
+ cy.findByText(newPost)
+ .then($el => $el.length !== 0)
+ .then(userIsAuthenticated => {
+ if (userIsAuthenticated) {
+ cy.log("signupV3: Authentication check passed ✅");
+ user = previousUserData.user;
+ } else {
+ // removed the stored token
+ localStorage.removeItem("jwt");
+ }
+ });
+ }
+ cy.then(() => {
+ if (user) {
+ return user;
+ }
+ cy.log("signupV3: Authentication check failed ❌ a new user is going to be registered");
// ... the rest of the command code...
});
Step by step:
- first of all, we need to check that the user and the "previous" user are the same. We could do so checking the
cy.signup()
parameters to understand what email the test wants to have registered. And we must check that a "previous user" exists
if (previousUserData.jwt && previousUserData.email === email) {
// ...
}
- the special "user restore" capability must be optional, with a new
ignoreLocalStorage
option
if (
!ignoreLocalStorage &&
previousUserData.jwt &&
previousUserData.email === email
) {
// ...
}
- the previous user's token is set
localStorage.setItem("jwt", previousUserData.jwt);
- then, we must check that the token is still valid for the back-end application. It should not be a problem (unless the tests last for hours) but you never know, test independence and stability must not be put at risk by a missing control. Obviously, this check must be performend from the user perspective, as we read in the @testing-library/cypress chapter
cy.visit("/")
// use the "New Post" string to detect if the user is authenticated or not
cy.findByText(newPost)
.then($el => $el.length !== 0)
.then(userIsAuthenticated => {
// now you know if the user is authenticated or not
});
- if the user is authenticated that's ok, it he/she is not, we must restore the initial front-end state removing the
jwt
token
.then(userIsAuthenticated => {
if (userIsAuthenticated) {
cy.log("signupV3: Authentication check passed ✅");
user = previousUserData.user;
} else {
// removed the stored token
localStorage.removeItem("jwt");
}
- last but not least: the previous register flow must be skipped
if (user) {
return user;
}
Take a look at the improvement in terms of test duration, we started from the original cy.signupV1
command
then, we improved that with App Actions
and finally, we leveraged the previously registered user
You find the whole command code below
File: cypress/support/signup/signup-v3.js
/// <reference types="Cypress" />
import { paths } from "../../../realworld/frontend/src/components/App";
import { noArticles } from "../../../realworld/frontend/src/components/ArticleList";
import { newPost } from "../../../realworld/frontend/src/components/Header";
let previousUserData = {};
Cypress.Commands.add(
"signupV3",
({ email, username, password, ignoreLocalStorage = false } = {}) => {
let user;
// if the user data match the previous one
if (!ignoreLocalStorage && previousUserData.jwt && previousUserData.email === email) {
cy.log("signupV3: Authentication check ⚠️");
// set the stored token
localStorage.setItem("jwt", previousUserData.jwt);
cy.visit("/")
// use the "New Post" string to detect if the user is authenticated or not
cy.findByText(newPost)
.then($el => $el.length !== 0)
.then(userIsAuthenticated => {
if (userIsAuthenticated) {
cy.log("signupV3: Authentication check passed ✅");
user = previousUserData.user;
} else {
// removed the stored token
localStorage.removeItem("jwt");
}
});
}
cy.then(() => {
if (user) {
return user;
}
cy.log("signupV3: Authentication check failed ❌ a new user is going to be registered");
const random = Math.floor(Math.random() * 100000);
user = {
username: username || `Tester${random}`,
email: email || `user+${random}@realworld.io`,
password: password || "mysupersecretpassword"
};
// set up AJAX call interception
cy.intercept("POST", "**/api/users").as("signup-request");
cy.visit(paths.register);
cy.window()
.its("appActions")
.invoke("signup", user);
// ... and AJAX call waiting
cy.wait("@signup-request").should(interception => {
expect(interception.request.body).deep.equal({
user: {
username: user.username,
email: user.email,
password: user.password
}
});
expect(interception.response.statusCode).to.equal(200);
cy.wrap(interception.response.body)
.should("have.property", "user")
.and(
user =>
expect(user)
.to.have.property("token")
.and.to.be.a("string").and.not.to.be.empty
)
.and("deep.include", {
username: user.username.toLowerCase(),
email: user.email
});
});
// end of the flow
cy.findByText(noArticles).should("be.visible");
// wait until the localStorage token is saved
cy.wrap(localStorage)
.its("jwt")
.should(jwt => expect(jwt).to.be.a("string").and.not.to.be.empty)
.then(jwt => {
previousUserData = {
jwt,
username,
email,
password,
user
};
})
.then(() => user);
});
}
);
Remember that you can implement shared utilities or the above flow the way you want, but always keep in mind that you must care as much as you can about test independency and test stability. Both of them are really important for a good testing experience, one that does not make you hate the test suite.
With the new cy.signupV3
custom command we are sure that:
the first user is registered as usual
if it's possible, the test takes advantage of the previously registered user, saving precious seconds
if something goes wrong and the back-end does not recognize the previous user, a new one is registered
In the end: every test that leverages the cy.signupV3
command gets a registered user, the fastest possible way.
Please note: the performance improvements vary for every application, we artificially slowed down the RealWorld for the sake of this course.
Author: Stefano Magni