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 fixturecy.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 openFor 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 π