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.
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:
- 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.
- 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:
- โ๏ธ Setup: spin up the application and mock network requests (reuse data from stories)
- ๐ค Action: use Cypress to visit pages and simulate interactions
- โ 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.