Writing UI tests for the web is hard, a lot of times the tests are brittle and unmaintainable. You must run them multiple times and, sometime, you need to exclude some of them from your pipeline.
Β
I'm going to explain to you the most important best practices that allow you to master the UI tests hell. I'm going to show you how much a tool like Cypress can help you writing and maintaining your UI tests.
Β
β’ HTML - π semantic markup
β’ CSSΒ - π visual regressions
β’ JSΒ Β - πΉ interaction flows
β’ APIΒ - π client/server communication
β’ βοΈ everything is asynchronous
β’ π¦ E2E tests need a lot of reliable data
β’ π debugging E2E tests is hard
β’ π the browser is slow, the UI is slow, the network is slow
β’ β¨οΈ no UI
β’ π no step-by-step test utilities
β’ π no integrated utilities
β’ π€ no clear reasons for failures
Suffers from | Full E2E test | UI integration test |
---|---|---|
the network | Yes π | No π |
the server | Yes π | No π |
data resetting | Yes π | No π |
complex case data | Yes π | No π |
Full E2E test | UI integration test | |
---|---|---|
confidence | 100% πͺ | 90% π€·ββοΈ |
performance* | ~20"/test π | ~2.5"/test π |
* benchmarking the Conio's back office test suite (env: staging)
// Cypress example
cy.server();
cy.route({
url: "/auth/token",
method: "POST",
response: "fixture:authentication-success.json"
})
cy.visit("/login")
cy.get(".username").type("stefano@conio.com")
cy.get(".password").type("mysupersecretpassword")
cy.get(".btn-primary").click()
// the front-end app is going to receive the content
// of the authentication-success.json fixture
cy.route({
url: "/auth/token",
method: "POST",
response: "fixture:unexisting-user.json"
})
cy.route({
url: "/auth/token",
method: "POST",
response: "fixture:already-registered-user.json"
})
cy.route({
url: "/auth/token",
method: "POST",
response: {},
status: 401
})
cy.route({
url: "/auth/token",
method: "POST",
response: {},
status: 500
})
cy.visit("/login")
cy.get(".username").type("stefano@conio.com")
cy.get(".password").type("mysupersecretpassword")
cy.get(".btn-primary").click()
* the Conio's back office test suite is composed by 80 UI integration tests and 12 E2E tests
cy.server();
cy.route({
url: "/auth/token",
method: "POST",
response: "fixture:authentication-success.json"
}).as("login-request")
cy.visit("/login")
cy.get(".username").type("stefano@conio.com")
cy.get(".password").type("mysupersecretpassword")
cy.get(".btn-primary").click()
cy.wait("@login-request").then(xhr => {
expect(xhr.request.body).to.have.property("username", "stefano@conio.com")
expect(xhr.request.body).to.have.property("password", "mysupersecretpassword")
})
cy.server();
cy.route({
url: "/auth/token",
method: "POST"
}).as("login-request")
cy.visit("/login")
cy.get(".username").type("stefano@conio.com")
cy.get(".password").type("mysupersecretpassword")
cy.get(".btn-primary").click()
cy.wait("@login-request").then(xhr => {
expect(xhr.status).to.equal(200)
expect(xhr.response.body).to.have.property("access_token")
expect(xhr.response.body).to.have.property("refresh_token")
})
Β
It highlights the changes in a text document
// Cypress waits up to 60 seconds for a page load
cy.visit("/login")
// up to 4 seconds for an element appearing
cy.get(".username").type("stefano@conio.com")
// up to 5 seconds for an XHR request and
// up to 30 seconds for the XHR response
cy.wait("@login-request")
// and if cypress cannot wait for something, you can use my own plugin
cy.waitUntil(() => cy.getCookie("token")
.then(cookie => Boolean(cookie && cookie.value)))
// front-end
if(window.Cypress) {
window.conioApp.store = reduxStore
}
// Cypress
window.conioApp.store.dispatch({
type: "LOGIN_REQUEST",
"stafeno@conio.com",
"mysupersecretpassword"
})
// instead of...
cy.visit("/login")
// you can
import {LOGIN_PATH} from "@/navigation/paths";
cy.visit(LOGIN_PATH)
// and if "/login" becomes "/sign-in" you will not
// face an annoying test failure
// Cypress example
cy.server();
cy.route("POST", "/auth/token").as("login-request");
cy.visit(LOGIN_PATH)
cy.get(".username").type("stefano@conio.com")
cy.get(".password").type("mysupersecretpassword")
cy.get(".btn-primary").click()
cy.wait("@route_login").then(xhr => {
expect(xhr.request.body).to.have.property("username", "stefano@conio.com")
expect(xhr.request.body).to.have.property("password", "mysupersecretpassword")
expect(xhr.status).to.equal(200)
expect(xhr.response.body).to.have.property("access_token")
expect(xhr.response.body).to.have.property("refresh_token")
});
cy.get("#success-feedback").should("exist")
cy.url().should("not.include", LOGIN_PATH)
{
"cy:test:integration": "cypress run --spec \"cypress/**/*.integration.*\"",
"cy:test:e2e": "cypress run --spec \"cypress/**/*.e2e.*\""
}
Type | Filename | When |
---|---|---|
integration tests | <name>.integration.test.js | β’ development β’ CI pipeline |
e2e tests | <name>.e2e.test.js | β’ post CI pipeline β’ cron |
monitoring tests | <name>.monitoring.test.js | β’ cron |
// instead of testing the front-end through selectors...
cy.get(".username").type("stefano@conio.com")
cy.get(".password").type("mysupersecretpassword")
cy.get(".btn-primary").click()
cy.get("#success-feedback").should("exist")
// ... we can test it though contents, like the user does
import {
USERNAME,
PASSWORD,
LOGIN,
SUCCESS_FEEDBACK
} from "@/components/LoginForm/strings"
cy.getByText(USERNAME).type("stefano@conio.com")
cy.getByText(PASSWORD).type("mysupersecretpassword")
cy.getByText(LOGIN).click()
cy.getByText(SUCCESS_FEEDBACK).should("exist")
# headlessly
$ cypress run
# with its UI
$ cypress open
For component development and tests: take a look at Storybook and its storyshots addon.
I and Jaga SantagostinoΒ are preparing a two-day workshop about everything related to JavaScript testing.
Β
The first one will be about React testing.
Β
Drop us a line if you want to know more about it π£
I've just begun writing it, all contributions are welcome π