Cypress tests organizing (or any e2e/ui tests)

Nowadays cypress is quite a hype technology (I am saying this in Fall 2020, everyone knows how rapidly js world can change ;))

"Cypress is a next generation front end testing tool built for the modern web."

https://docs.cypress.io/guides/overview/why-cypress.html#In-a-nutshel

I had some experience with Behat and other types of tests like unit/integarational, but today I will tell you about e2e.

When I wrote Behat tests I didn't think a lot about how to structure my test cases in a maintainable way. This led me to the antipattern called "Single-Layer Architecture"[1].

Extending such tests quickly becomes a mess and tends to become an ineffective work:

  1. You can't focus on business logic, you always need to remember low-level details to introduce a new step definition
  2. Your low-level solutions are often duplicated, and you even don't notice that
  3. Changing testing framework means rewriting or updating all step definitions!

Fortunately there is an approach which is very easy to follow and which mitigates mentioned issues.

First time I saw it in the Martin's Fowler article Page Objects [2].

I wonder why a lot of testing frameworks documentations including cypress documentation[4] don't scream about this from the first pages.

Anyway, let's consider a folder structure of an example project[5] with cypress tests.

Scenarios lay here: https://github.com/harentius/cypress-example/tree/main/tests/e2e/features

Let's look at one of them:

 // features/Login.js
import Login from '../support/business/Login';

describe('Login', () => {
  it("Login with wrong credentials shows error message", () => {
    Login.open();

    Login.fillCredentials('wrong_name', 'wrong_password');
    Login.submitForm();

    Login.assertNotification('Incorrect username or password.');
  })
})

As you can see, it is well structured and semantic. If you prefer gherkin syntax, migration will be a rather technical and straightforward task: steps decomposition is already done.

It doesn't know at all which testing framework is used or how actions and assertions perform, or about the "driver" which is used to work with the framework. It uses high-level commands to perform actions and asserting.

We have 2 implementation layers for scenario(s):

First is https://github.com/harentius/cypress-example/tree/main/tests/e2e/support/business

Here lays dedicated class which backends login process scenario:

// support/business/Login.js
import Navigation from '../driver/Navigation';
import Form from '../driver/Form';
import Page from '../driver/Page';

class Login
{
  static open()
  {
    Navigation.visit('/login')
  }

  static fillCredentials(username, password)
  {
    Form.fill('#login_field', username);
    Form.fill('#password', password);
  }

  static submitForm()
  {
    Form.submit('form');
  }

  static assertNotification(notification)
  {
    Page.assertElementContains('.flash', notification)
  }
}

export default Login

It does know about the driver layer and uses it as a backend to achieve testing goals, but doesn't know about testing framework (cypress).

It uses only the limited API provided by the second "driver" layer.

https://github.com/harentius/cypress-example/tree/main/tests/e2e/support/driver

This layer makes a convenient API for your specific project.

// driver/Page.js
class Page {
  static click(text) {
    cy.contains(text).click();
  }

  static clickElement(selector, text) {
    let e = cy.get(selector);

    if (text !== null) {
      e = e.contains(text);
    }

    e.click();
  }

  static assertContains(text) {
    return cy.contains(text);
  }

  static assertElementContains(selector, text) {
    return cy.get(selector).contains(text);
  }
}

export default Page;
// driver/Navigation.js
class Navigation {
  static visit(url) {
    cy.visit(url);
  }

  static assertPageUrlIs(url) {
    cy.location().its('pathname').should('eq', url);
  }
}

export default Navigation;
// driver/Form.js
class Form
{
  static fill(selector, text) {
    cy.get(selector).type(text);
  }

  static submit(selector) {
    cy.get(selector).get('input[type="submit"]').click();
  }
}

export default Form

When you play for a while with this approach you will realise that:

  1. "Driver" layer is surprisingly thin. Basically you need a very limited amount of commands to create e2e tests, like "open page", "assert that content exists", "find by some locator"
  2. Nevertheless it is thin, it prevents duplication of the knowledge about the system. Remember, bad is not the code copy-paste itself but the copy-paste of knowledge about system. And that is exactly what happens when you spam with "cy.get(...)" in every scenario
  3. Even if you don't plan to change testing framework, this approach makes your tests cleaner

You can check/run this example project here.

References

  1. What Not to Do When Writing E2E Tests https://medium.com/better-programming/what-not-to-do-when-writing-e2e-tests-ef7b9d09cc81
  2. PageObject https://martinfowler.com/bliki/PageObject.html
  3. The Practical Test Pyramid https://martinfowler.com/articles/practical-test-pyramid.html
  4. Introduction to Cypress https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html
  5. https://github.com/harentius/cypress-example