Modern Front-End Testing with Cypress

Last updated January 31st, 2019

Cypress is a modern web automation test framework designed to simplify browser testing. While it's best known as a Selenium replacement, it's much more than just an end-to-end test automation tool. Cypress is a developer tool made to be used proactively by developers rather than a non-technical QA team focused on after-the-fact testing.

This post looks at how to introduce Cypress into your test-driven development workflow as you build out an app with Flask and React.

Contents

Sample Application

We'll be building a basic todo application with Flask and React based on the following user stories:

  1. As a user I can see all the todos in the list
  2. As a user I can add new todos to the list
  3. As a user I can toggle the completed state of each todo

Let's assume that our focus is on the client-side only. In other words, we need to create a React todo app that interacts with a Flask back-end via AJAX to get and add todos. If you'd like to code along, clone down the flask-react-cypress repo, and then check out the v1 tag to the master branch:

$ git clone https://github.com/testdrivenio/flask-react-cypress --branch v1 --single-branch
$ cd flask-react-cypress
$ git checkout tags/v1 -b master

Workflow

This workflow focuses on integration testing, where development and testing happen simultaneously using a TDD-like approach:

  1. Convert user stories, requirements, and acceptance criteria into partial test specs
  2. Add fixtures and stub out network calls
  3. Run the Cypress GUI and keep it open next to your code editor
  4. Use .only to focus, and iterate, on a single test
  5. Ensure the test fails
  6. Code until that test passes (red, green, refactor)
  7. Repeat the previous three steps until all tests are green
  8. Optional: Convert the integration tests to end-to-end tests by removing the network stubs

Want to see this workflow in action? Check out the My Cypress Workflow video.

Initial Setup

Steps:

  1. Convert user stories, requirements, and acceptance criteria into partial test specs
  2. Add fixtures and stub out network calls
  3. Run the Cypress GUI and keep it open next to your code editor

Create partial test specs

Add the partial test specs to a new file called client/cypress/integration/todos.spec.js:

describe('todo app', () => {
  beforeEach(() => {
    cy.visit('/');
    cy.get('h1').contains('Todo List');
  });

  it('should display the todo list', () => {});

  it('should add a new todo to the list', () => {});

  it('should toggle a todo correctly', () => {});
});

Then, add the baseUrl along with the serverUrl--the URL for the server-side Flask app--under an env key so it will be accessible as an environment variable to client/cypress.json:

{
  "baseUrl": "http://localhost:3000",
  "env": {
    "serverUrl": "http://localhost:5009"
  }
}

Add fixtures

Before we stub out the requests, let's add the fixture files, which will be used to simulate the data returned from the following server-side endpoints:

  1. GET - /todos - get all todos
  2. POST - /todos - add a todo

client/cypress/fixtures/todos/all_before.json:

{
  "data": {
    "todos": [
      {
        "complete": false,
        "created_date": "Mon, 28 Jan 2019 15:32:28 GMT",
        "id": 1,
        "name": "go for a walk"
      },
      {
        "complete": false,
        "created_date": "Mon, 28 Jan 2019 15:32:28 GMT",
        "id": 2,
        "name": "go for a short run"
      },
      {
        "complete": true,
        "created_date": "Mon, 28 Jan 2019 15:32:28 GMT",
        "id": 3,
        "name": "clean the stereo"
      }
    ]
  },
  "status": "success"
}

client/cypress/fixtures/todos/add.json:

{
  "name": "make coffee"
}

This final fixture is for the getting of all todos after a new todo has been added.

client/cypress/fixtures/todos/all_after.json:

{
  "data": {
    "todos": [
      {
        "complete": false,
        "created_date": "Mon, 28 Jan 2019 15:32:28 GMT",
        "id": 1,
        "name": "go for a walk"
      },
      {
        "complete": false,
        "created_date": "Mon, 28 Jan 2019 15:32:28 GMT",
        "id": 2,
        "name": "go for a short run"
      },
      {
        "complete": true,
        "created_date": "Mon, 28 Jan 2019 15:32:28 GMT",
        "id": 3,
        "name": "clean the stereo"
      },
      {
        "complete": false,
        "created_date": "Mon, 28 Jan 2019 17:22:35 GMT",
        "id": 4,
        "name": "drink a beverage"
      }
    ]
  },
  "status": "success"
}

Then, add the fixtures to the beforeEach in the test spec:

beforeEach(() => {
  // fixtures
  cy.fixture('todos/all_before.json').as('todosJSON');
  cy.fixture('todos/add.json').as('addTodoJSON');
  cy.fixture('todos/all_after.json').as('updatedJSON');

  cy.visit('/');
  cy.get('h1').contains('Todo List');
});

Stub network calls

Update the beforeEach again, adding a stub and an explicit wait:

beforeEach(() => {
  // fixtures
  cy.fixture('todos/all_before.json').as('todosJSON');
  cy.fixture('todos/add.json').as('addTodoJSON');
  cy.fixture('todos/all_after.json').as('updatedJSON');

  // network stub
  cy.server();
  cy.route('GET', `${serverUrl}/todos`, '@todosJSON').as('getAllTodos');


  cy.visit('/');
  cy.wait('@getAllTodos');
  cy.get('h1').contains('Todo List');
});

Assign the value of the serverUrl environment variable to a variable:

const serverUrl = Cypress.env('serverUrl');

Add stubs to the should add a new todo to the list test:

it('should add a new todo to the list', () => {
  // network stubs
  cy.server();
  cy.route('GET', `${serverUrl}/todos`, '@updatedJSON').as('getAllTodos');
  cy.route('POST', `${serverUrl}/todos`, '@addTodoJSON').as('addTodo');
});

Open Cypress

Run the React app in one terminal window:

$ cd client
$ npm install
$ npm start

Then, open the Cypress GUI in a different window:

$ cd client
$ ./node_modules/.bin/cypress open

cypress gui

cypress gui

Development

Steps:

  1. Use .only to focus, and iterate, on a single test
  2. Ensure the test fails
  3. Code until that test passes (red, green, refactor)
  4. Repeat the previous three steps until all tests are green

Displays all todos

Update the test:

it.only('should display the todo list', () => {
  cy.get('li').its('length').should('eq', 3);
  cy.get('li').eq(0).contains('go for a walk');
});

cypress gui

Then, update the App component:

import React, { Component } from 'react';
import axios from 'axios';

class App extends Component {
  constructor() {
    super();
    this.state = {
      todos: []
    };
  };
  componentDidMount() {
    this.getTodos();
  };
  getTodos() {
    axios.get('http://localhost:5009/todos')
    .then((res) => { this.setState({ todos: res.data.data.todos }); })
    .catch((err) => { });
  };
  render() {
    return (
      <div className="App">
        <section className="section">
          <div className="container">
            <div className="columns">
              <div className="column is-half">
                <h1 className="title is-1">Todo List</h1>
                <hr/>
                <ul type="1">
                  {this.state.todos.map(todo =>
                    <li key={ todo.id } style={{ fontSize: '1.5rem' }}>{ todo.name }</li>
                  )}
                </ul>
              </div>
            </div>
          </div>
        </section>
      </div>
    );
  };
};

export default App;

Within componentDidMount, an AJAX request is sent to the server-side to get all todos. When the response comes back, setState is called inside the success handler, which re-renders the component, displaying the todo list.

The test should now pass:

cypress gui

Add a todo

Remove the .only from the passing test, and update the next test:

it.only('should add a new todo to the list', () => {
  // network stubs
  cy.server();
  cy.route('GET', 'http://localhost:5009/todos', '@updatedJSON').as('getAllTodos');
  cy.route('POST', 'http://localhost:5009/todos', '@addTodoJSON').as('addTodo');

  // asserts
  cy.get('.input').type('drink a beverage');
  cy.get('.button').contains('Submit').click();
  cy.wait('@addTodo');
  cy.wait('@getAllTodos');
  cy.get('li').its('length').should('eq', 4);
  cy.get('li').eq(0).contains('go for a walk');
  cy.get('li').eq(3).contains('drink a beverage');
});

cypress gui

Again, update the component:

import React, { Component } from 'react';
import axios from 'axios';

class App extends Component {
  constructor() {
    super();
    this.state = {
      todos: [],
      input: ''
    };
    this.handleChange = this.handleChange.bind(this);
    this.addTodo = this.addTodo.bind(this);
  };
  componentDidMount() {
    this.getTodos();
  };
  getTodos() {
    axios.get('http://localhost:5009/todos')
    .then((res) => { this.setState({ todos: res.data.data.todos }); })
    .catch((err) => { });
  };
  handleChange(e) {
    this.setState({ input: e.target.value });
  };
  addTodo() {
    if(this.state.input.length) {
      axios.post('http://localhost:5009/todos', { name: this.state.input })
      .then((res) => { this.getTodos(); })
      .catch((err) => { });
    }
  };
  render() {
    return (
      <div className="App">
        <section className="section">
          <div className="container">
            <div className="columns">
              <div className="column is-half">
                <h1 className="title is-1">Todo List</h1>
                <hr/>
                <div className="content">
                  <div className="field has-addons">
                    <div className="control">
                      <input
                        className="input"
                        type="text"
                        placeholder="Add a todo"
                        onChange={ this.handleChange }
                      />
                    </div>
                    <div className="control" onClick={ this.addTodo }>
                      <button className="button is-info">Submit</button>
                    </div>
                  </div>
                  <ul type="1">
                    {this.state.todos.map(todo =>
                      <li key={ todo.id } style={{ fontSize: '1.5rem' }}>{ todo.name }</li>
                    )}
                  </ul>
                </div>
              </div>
            </div>
          </div>
        </section>
      </div>
    );
  };
};

export default App;

Todos can now be added via an input field. The onChange event is fired, which updates the state, anytime the input value is changed. After the submit button is clicked, an AJAX POST request is sent to the server along with the value from the input field. The todo list is updated when a successful response is returned.

cypress gui

Toggle completed state

Test:

it.only('should toggle a todo correctly', () => {
  cy
    .get('li')
    .eq(0)
    .contains('go for a walk')
    .should('have.css', 'text-decoration', 'none solid rgb(74, 74, 74)');
  cy.get('li').eq(0).contains('go for a walk').click();
  cy
    .get('li')
    .eq(0).contains('go for a walk')
    .should('have.css', 'text-decoration', 'line-through solid rgb(74, 74, 74)');
});

cypress gui

Component:

import React, { Component } from 'react';
import axios from 'axios';

class App extends Component {
  constructor() {
    super();
    this.state = {
      todos: [],
      input: ''
    };
    this.handleChange = this.handleChange.bind(this);
    this.addTodo = this.addTodo.bind(this);
    this.handleClick = this.handleClick.bind(this);
  };
  componentDidMount() {
    this.getTodos();
  };
  getTodos() {
    axios.get('http://localhost:5009/todos')
    .then((res) => { this.setState({ todos: res.data.data.todos }); })
    .catch((err) => { });
  };
  handleChange(e) {
    this.setState({ input: e.target.value });
  };
  addTodo() {
    if(this.state.input.length) {
      axios.post('http://localhost:5009/todos', { name: this.state.input })
      .then((res) => { this.getTodos(); })
      .catch((err) => { });
    }
  };
  handleClick(id) {
    this.setState ({
      todos: this.state.todos.map (todo => {
        if (todo.id === id) {
          todo.complete = !todo.complete;
        }
        return todo;
      }),
    });
  };
  render() {
    return (
      <div className="App">
        <section className="section">
          <div className="container">
            <div className="columns">
              <div className="column is-half">
                <h1 className="title is-1">Todo List</h1>
                <hr/>
                <div className="content">
                  <div className="field has-addons">
                    <div className="control">
                      <input
                        className="input"
                        type="text"
                        placeholder="Add a todo"
                        onChange={ this.handleChange }
                      />
                    </div>
                    <div className="control" onClick={ this.addTodo }>
                      <button className="button is-info">Submit</button>
                    </div>
                  </div>
                  <ul type="1">
                    {this.state.todos.map(todo =>
                      <li
                        key={ todo.id }
                        style={{
                          textDecoration: todo.complete ? 'line-through' : 'none',
                          fontSize: '1.5rem',
                        }}
                        onClick={() => this.handleClick(todo.id)}
                      >
                        { todo.name }
                      </li>
                    )}
                  </ul>
                </div>
              </div>
            </div>
          </div>
        </section>
      </div>
    );
  };
};

export default App;

When a todo li is clicked, handleClick is fired, which then toggles the value of the todo.complete boolean. If the boolean is true then the todo's text-direction will be set to line-through.

cypress gui

Remove the .only from the final test.

End-to-End Tests

Finally, let's convert the integration tests to end-to-end tests by removing the network stubs and fixtures. We'll do a bit of refactoring as well.

client/cypress/integration/todos-e2e.spec.js:

const serverUrl = Cypress.env('serverUrl');

describe('todo app - e2e', () => {
  beforeEach(() => {
    // network call
    cy.server();
    cy.route('GET', `${serverUrl}/todos`).as('getAllTodos');

    cy.visit('/');
    cy.wait('@getAllTodos');
    cy.get('h1').contains('Todo List');
  });

  it('should display the todo list', () => {
    cy.get('li').its('length').should('eq', 2);
    cy.get('li').eq(0).contains('walk');
  });

  it('should toggle a todo correctly', () => {
    cy
      .get('li')
      .eq(0)
      .contains('walk')
      .should('have.css', 'text-decoration', 'none solid rgb(74, 74, 74)');
    cy.get('li').eq(0).contains('walk').click();
    cy
      .get('li')
      .eq(0).contains('walk')
      .should('have.css', 'text-decoration', 'line-through solid rgb(74, 74, 74)');
  });
});

Before you run these, you will need to spin up the server-side Flask app along with Postgres and create and seed the database:

$ cd server
$ docker-compose up -d --build
$ docker-compose exec web python manage.py recreate_db
$ docker-compose exec web python manage.py seed_db

cypress gui

Now, since the full end-to-end tests require the Flask app to be running, you may not want to run them locally as you're developing. To ignore them, add the ignoreTestFiles config variable to the cypress.json file:

{
  "baseUrl": "http://localhost:3000",
  "env": {
    "serverUrl": "http://localhost:5009"
  },
  "ignoreTestFiles": "*e2e*"
}

You can then run them on other environments by using a different configuration file.

Conclusion

Cypress is a powerful tool that makes it easy to set up, write, run, and debug tests. Hopefully, this post showed you how easy it is to incorporate Cypress into your development workflow.

Resources:

  1. Final code
  2. My Cypress Workflow video
  3. Cypress'ing Your Way to a Better Night's Sleep slides
  4. Sliding Down the Testing Pyramid blog post
Featured Course

The Definitive Guide to Celery and Django

Learn how to add Celery to a Django application to provide asynchronous task processing.

Featured Course

The Definitive Guide to Celery and Django

Learn how to add Celery to a Django application to provide asynchronous task processing.