Integration testing, tips and tricks. Cypress, Page Objects, Application Actions, Cucumber

Today, in the era of mature web technologies, developers have a wide choice of various libraries, CSS frameworks, React components, etc. As a result, the development of web applications became the art of mixing and matching. Not a surprise, integration testing is also widely adopted as an important part of the development process. Luckily, new and emerging tools make e2e and integration testing more and more affordable. If previously integration testing was often done by separate developers working usually in big companies, today, for many, it is a starting point of development process. Indeed, in the last decade the idea to start development from user stories was rapidly gaining traction. Now many teams fall back to ordinary TDD only in case of BDD test failure (so called Double Loop Testing). For those who missed the trend or just looking for fun facts/overview, here is some assortment of techniques of BDD testing using popular Cypress testing framework.

Commands and Page Objects

Writing Cypress tests is very easy. After simple installation you are ready to go: just write your first spec in cypress/integration folder. While getting started and writing tests is simple, test structure deserves some consideration. Just to demonstrate some common techniques, lets take the simplest counter component as an example:

export const Counter: React.FC = () => {
    const [counter, setCounter] = useState<number>(0)

    return (
        <div data-testid="counter">
            <button onClick={() => setCounter(v => v + 1)}>Inc</button>
            <div>{counter}</div>
        </div>
    )
}

As CSS selectors could be used to reference HTML elements, code to check counter value is simple enough:

it('counter is incremented on click', () => {
    cy.visit()
    cy.get(`[data-testid="counter"]`).contains("Inc").click()
    cy.get(`[data-testid="counter"]`).find(">div").should("contain", "1")
})

The problem with the code above is that we select counter component in two places: when searching for button and when verifying counter value. In this particular case this could be easily solved, for example, by simple aliasing .as("@counter"). But, what if the component tested is much bigger, contains many elements and non-trivial logic, or is referenced in multiple test cases, or maybe even in multiple specs?

The first impulse could be to use Cypress commands. Though a useful tool, it doesn't look like a good fit in this particular case. What are the drawbacks? First, such commands will eventually inevitably accumulate a lot of Domain logic. Event in our, simplest case, we will need to follow Domain specific naming conventions, in DDD parlance, use so called Ubiquitous Language. Cypress specific files are evidently a wrong place for anything application specific. Especially taking into account that the command is a limited and non-standard concept. So it is better to reserve commands for generic, widely reused, test specific helpers. For example, in our case, we can use commands to create getByTestId helper function:

// cypress/support/commands.js
Cypress.Commands.add('getByTestId', {
    prevSubject: 'optional'
  }, (subject, id) => {
    if (subject) {
        return subject.find(`[data-testid="${id}"]`)
    } else {
      return cy.get(`[data-testid="${id}"]`)
    }
}

But to encapsulate app specific access patterns it is better to use standard JavaScript classes:

export class PageObject {
    counterBlock () {
        return cy.getByTestId("counter")
    }
}

In which case we can rewrite our test as:

it('counter is incremented on click', () => {
    const page = new PageObject()
    cy.visit()
    page.counterBlock().contains("Inc").click()
    page.counterBlock().find(">div").should("contain", "1")
})

Now, it looks much better: element selection is encapsulated in one function. One may wish not to stop here but proceed further, encapsulating access to other elements of component (button and counter). As Cypress test files may import any files from the project, PageObject could be defined near component itself, so it can easily reuse code from application (or Jest fixtures). The technique demonstrated is known under the name Page Object and is widely adopted across developers community.

Words of caution are in order though: when using Page Object, tests may become strongly coupled with HTML mark-up and fragile. This is because developers can be tempted to overgeneralize, turning Page Object into essentially powerful API, capable of full application control. Which is actually non-trivial task, requiring a lot of development effort without any business value.

Still, for those who are able to avoid pitfalls of overgeneralization, page objects may serve as a convenient and helpful tool. At the very least, it is after all a better alternative than Cypress commands.

Application actions

An alternative technique to control tested page goes under the name Application actions. The idea is that since Cypress tests run in the same environment as page code, tests and application can interact with each other using global model (API) objects. Application may, for example, conditionally, for non-production environments, expose some API:

if (process.env.NODE_ENV !== 'production') {
    window["app"] = new ApplicationActions()
}

When such an API becomes available, tests gain much better control over tested components. For example, instead of reproducing long sequences of user actions, test may just set application state, reusing already existing application codebase. Consider a capped counter component with maximum value 10, reached after first 10 clicks (a very contrived, artificial example, just to illustrate the point):

export const CappedCounter: React.FC = () => {
    const counter = useCounter()

    return (
        <div data-testid="capped-counter">
            <button onClick={() => dispatch(incCounter())}>Inc</button>
            <div>{counter > 10 ? 10 : counter}</div>
        </div>
    )
}

Without access to any API test would need to click button 10 times to reach the upper limit (which we, supposedly, would like to test). Things change if we add some API:

export class ApplicationActions {
    select(selector) {
        return store.select(selector)
    }

    dispatch(action: any) {
        store.dispatch(action)
    }

    getCounter = () => {
        return this.select(getCounter)
    }

    setCounter = (v: number) => {
        return this.dispatch(setCounter(v))
    }
}

When developing applications using Redux, such API comes practically for free: thanks to reuse of application selectors/actions. In case of counter, Redux actions could be used to quickly setup initial counter state:

it.only('capped counter', () => {
    const page = new PageObject()
    cy.visit()
    cy.window().its('app').invoke("setCounter", 10)
    page.cappedCounterBlock().find(">div").should("contain", "10")
})

Although sometimes Application Actions are contrasted with Page Objects, this example demonstrates that, they can play nicely together. Page Object even could be used to hide Application Action invocation boilerplate.

In addition to easy initial test setup, Application Actions, open many possibilities: assertions on application state, logging, synchronization, etc. Again, this powerful tool comes with responsibility: after all one must always remember, that our goal is to test features, not the implementation.

Cucumber

The main idea of BDD is that examples of user behavior is the best specification. Most consistently and explicitly this idea is implemented in Cucumber tests. There, every test is first written using standard "given", "when", "then" format in ordinary text *.feature files:

Feature: Likes

  Scenario: Simple counter
    Given I opened a page
    When I click on the button
    Then Counter becomes equal to "1"

Cucumber framework deserves separate article(s). Here there is just enough space to introduce the main idea.

In Cypress, plugin cypress-cucumber-preprocessor allows to add feature files and bind Scenario steps to their implementation in step files:

import { Given, When, Then } from "cypress-cucumber-preprocessor/steps";

Given('I opened a new page', () => {
  cy.visit()
})

....

Cucumber tests are basically ordinary Cypress tests, in which one may use all the above techniques. The main advantage of Cucumber over ordinary tests is up-to-date documentation. Even non-technical people will be able to read application specifications and understand them. This fosters communication between Domain experts and development team, which could be especially beneficial when business requirements are unclear/shifting. To take full advantage of the Cucumber one will need additional tooling though. While plugin itself is already relatively mature/feature complete, additional software will be required to make documentation easily accessible/maintainable by all team members.

Next

Integration testing is quite easy nowadays and comes with mature tools encouraging best practices. It is a good time to take advantage of the opening opportunities, incorporating available tools and best practices into day to day development workflows.