Testing user flows

Verify that your UI works end-to-end

Debugging in production is a nightmare. You have to check every layer of your app. Is it a component bug, a misfiring event, styling, app state, or perhaps a broken API? It could be any of the above, and you have to untangle why.

UIs help folks navigate a sequence of steps on multiple pages to accomplish their goals. Storybook makes it easy to isolate each of those pages and run visual, accessibility and interaction tests on them. But to verify the entire flow and catch integration issues, you need end-to-end (E2E) UI tests.

I researched ten leading front-end teams—Peloton, Shopify, O'Reilly and more—to understand how they apply E2E tests to check user flows. This article summarizes my findings. You'll also learn about the tooling involved and how E2E tests fit into your UI testing strategy.

Does your UI work end-to-end?

User flows aren’t contained to only one component, they involve multiple components working in tandem. Each interaction triggers state updates, route changes, and API calls that affect what’s rendered on screen. With all of these points of failure, it can be tough to QA them one-by-one.

Teams use E2E tests to ensure that the user experience works as intended. To run an E2E test, you start by spinning up a complete instance of an application. Then use tools like Cypress, Playwright, or Selenium to verify the user flow by simulating user behavior.

Apps are assembled by connecting components to data, business logic and APIs

Testing the complete application comes with trade-offs

On the surface, E2E and interaction tests seem pretty similar. But remember, your users interact with an app in addition to its constituent components. E2E tests run at the app level, which allows them to uncover integration issues between the frontend and backend. But that also requires you to maintain test infrastructure for more layers of your tech stack (time-consuming!).

Component level testing is done by self-contained tools which can mount, render and test components. With E2E tests, you're responsible for spinning up the application. For which, you have two options:

  1. Maintain a full test environment: this includes front-end, back-end, services, and seeded test data. For example, O'Reilly team uses Docker to spin up their entire app infrastructure and run E2E tests.
  2. Maintain a front-end only test environment paired with a mock back-end. For example, Twilio tests flows by using Cypress to stub out network requests.

Either way, the complexity ramps up with the scale of your system. The larger the system the more cumbersome it is to replicate the setup on a continuous integration server and then connect to a cloud browser to run the tests.

Given this trade-off, most teams use a hybrid approach to balance effort and value. E2E tests are limited to only critical user flows. Interaction tests are used to verify all other behavior.

In this tutorial, we’re E2E testing using Cypress with the mocked back-end approach. Here's a summary of the workflow:

  1. ⚙️ Setup: spin up the application and mock network requests (reuse data from stories)
  2. 🤖 Action: use Cypress to visit pages and simulate interactions
  3. Run assertions to verify that the UI updated correctly

Tutorial

Let’s see the workflow action with the Taskbox app that I introduced in an earlier post. We’ll write an E2E test for the authentication flow: navigate to the login page and fill in the user credentials. Once authenticated, the user should be able to see a list of their tasks.

Grab the code and load up the app in development mode by running yarn start. Then open http://localhost:3000 and you’ll be presented with the login screen.

Set up Cypress

Run: yarn add cypress --dev to install the Cypress package. Then add the Cypress command to the scripts field of your package.json file.

 "scripts": {
   "cypress": "cypress open"
 }

Next, add a cypress.json file at the root of your project. Here we can configure the base URL for our application so that we don’t have to repeat ourselves when writing out actual test commands.

// cypress.json
{
 "baseUrl": "http://localhost:3000"
}

And finally, run yarn cypress to finish up the setup process. This will add a cypress folder to your project. All the test files will live here. It will also start the Cypress test runner.

Testing the auth flow

Cypress test structure is quite similar to other types of testing you might be familiar with. You start by describing what you’re going to test. Each test lives in an it block where you run assertions. Here’s what the authentication user flow test looks like:

// cypress/e2e/auth.spec.js

describe('The Login Page', () => {
  it('user can authenticate using the login form', () => {
    const email = 'alice.carr@test.com';
    const password = 'k12h1k0$5;lpa@Afn';

    cy.visit('/');

    // Fill out the form
    cy.get('input[name=email]').type(email);
    cy.get('input[name=password]').type(`${password}`);

    // Click the sign-in button
    cy.get('button[type=submit]').click();

    // UI should display the user's task list
    cy.get('[aria-label="tasks"] li').should('have.length', 6);
  });
});

Let’s break down what’s happening here. cy.visit opens the browser to the login page of our application. Then we use the cy.get command to find and fill out the email and password fields. And finally, click the submit button to actually log in.

The last bit of the test runs assertions. In other words, we verify whether the authentication was successful. We do this by checking to see if the list of tasks is now visible.

Switch over to the Cypress window and you should see that the test is executed.

But, notice that the test failed. That’s because we’re only running the front-end of the application. All HTTP requests will fail since we don’t have an active back-end. Instead of spinning up the actual back-end we’ll use stubbed network requests.

Mocking requests

The cy.intercept method allows us to intercept network requests and respond with mock data. The auth user flow relies on two requests: /authenticate to log in and /tasks to fetch the user’s tasks. To stub these requests we're gonna need some mock data.

In a previous post, I demonstrated how you can catalogue all the use cases of a component using Storybook. During that process we created mock data for the task list in the TaskList.stories.js file. We can reuse that in our Cypress test.

import React from 'react';
import { TaskList } from './TaskList';
import Task from './Task.stories';

export default {
  component: TaskList,
  title: 'TaskList',
  argTypes: {
    ...Task.argTypes,
  },
};
const Template = (args) => <TaskList {...args} />;

export const Default = Template.bind({});
Default.args = {
  tasks: [
    { id: '1', state: 'TASK_INBOX', title: 'Build a date picker' },
    { id: '2', state: 'TASK_INBOX', title: 'QA dropdown' },
    {
      id: '3',
      state: 'TASK_INBOX',
      title: 'Write a schema for account avatar component',
    },
    { id: '4', state: 'TASK_INBOX', title: 'Export logo' },
    { id: '5', state: 'TASK_INBOX', title: 'Fix bug in input error state' },
    { id: '6', state: 'TASK_INBOX', title: 'Draft monthly blog to customers' },
  ],
};

Let’s go ahead and update the test to mock those two network requests.

// cypress/e2e/auth.spec.js

import { Default as TaskListDefault } from '../../src/components/TaskList.stories';

describe('The Login Page', () => {
  beforeEach(() => {
    cy.intercept('POST', '/authenticate', {
      statusCode: 201,
      body: {
        user: {
          name: 'Alice Carr',
          token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
        },
      },
    });

    cy.intercept('GET', '/tasks', {
      statusCode: 201,
      body: TaskListDefault.args,
    });
  });

  it('user can authenticate using the login form', () => {
    const email = 'alice.carr@test.com';
    const password = 'k12h1k0$5;lpa@Afn';

    cy.visit('/');

    // Fill out the form
    cy.get('input[name=email]').type(email);
    cy.get('input[name=password]').type(`${password}`);

    // Click the sign-in button
    cy.get('button[type=submit]').click();

    // UI should display the user's task list
    cy.get('[aria-label="tasks"] li').should('have.length', 6);
  });
});

Re-run the test and it should be passing now.

We booted up the full application. With Cypress, we were able to simulate user behaviour and test the login flow. In this one test, we checked data flow, form submission and API calls.

Conclusion

User experiences are assembled by composing components and wiring them up to data and APIs. Storybook makes it easy to test them at the component level. You capture use cases as stories and execute assertions using Jest and Testing Library. These tests are easy to maintain and provide a fast feedback loop. However, they don't allow you to test flows spanning multiple pages.

E2E tests, on the other hand, run on an entire instance of the app. You can test user flows and verify that all layers of the system are working as expected. The downside is the process requires significant time and effort.

Given this trade-off, most teams limit themselves to a small set of E2E tests for the core user flows. And use component-level tests for broader coverage.

Tests are only helpful if you are running them consistently. Leading engineering teams use a Continuous Integration (CI) server to run their full test suite—automatically on every code push. The next article will recap the complete UI testing strategy and show you how to automate that workflow. Join the mailing list to get notified as more UI testing articles are published.

Docs
Documentation
Add Storybook to your project in less than a minute to build components faster and easier.
Tutorial
Tutorials
Learn Storybook with in-depth tutorials that teaches Storybook best practices. Follow along with code samples.
Storybook
The MIT License (MIT). Website design by @domyen and the awesome Storybook community.

Maintained by
Chromatic
Continuous integration by
CircleCI
Hosting by
Netlify