A testing pyramid is an outline that structures automated tests. It compares the cost, speed, and effort of different types of testing and provides a guideline for what proportion of each test should be present in an automated test suite.
As your codebase grows in size, a proper test suite becomes necessary to diminish the frequency of costly bugs. Without a strategy, your test suite may become poorly designed, unreliable, and ineffective.
In this article, you’ll learn about the frontend testing pyramid, including what the different kinds of tests are and what ratio of these tests you should maintain in your automated test suite.
What is the Testing Pyramid Any new change you introduce to your codebase has the potential to be error-prone. Software errors have varying repurcussions and may bring severe risks.
To mitigate these risks, you need to have a quality assessment process in place, and automated tests should be an integral part of this process. Automated tests are the safety nets that provide you with quick feedback on the correctness of your codebase. However, you need different types of automated tests to develop confidence in your software.
Not all automated test types provide the same return on investment. For instance, end-to-end tests give good feedback on the overall workflow of the application from the end user’s perspective. However, they tend to be fragile, which can result in high maintenance costs. On the contrary, unit tests cannot guarantee the functional correctness of the application; however, they are cheap to write and easy to maintain.
For an effective automated test suite, you need to maintain the right balance of tests, and the testing pyramid provides a framework that addresses this balance.
The testing pyramid constitutes three distinct levels. Unit tests are at the bottom level, while integration tests and end-to-end tests make up the middle and the top levels, respectively. According to the testing pyramid , you should have a lot of unit tests, some integration tests, and very few end-to-end tests in comparison to the other types. As you move up the testing pyramid, each layer of testing represents a decrease in speed of execution and an increase in scope, complexity, and maintenance. Here’s what a testing pyramid looks like:
Types of Frontend Tests This article focuses on the frontend testing pyramid, and in the following sections, you’ll look at the different types of tests that you can use on the frontend.
Unit Tests Unit tests validate that the units within your codebase are working as expected. A unit is the smallest piece of code that encapsulates a small behavior and can be tested in isolation. A unit can be a function, a class, or even a method in a class.
When you refactor your code or introduce any new code, you can rerun all the unit tests to ensure the change doesn’t break existing functionalities. As each unit test acts on a small chunk of code, the execution happens in seconds and provides you with quick feedback. If any of the unit tests break, you can quickly isolate the root cause, which reduces your debugging efforts.
A JavaScript framework like Jest facilitates unit testing of your code. The following is an example of a unit test:
function sum(a, b) {
return a + b;
}
test('1 + 2 equals 3', () => {
expect(sum(1, 2)).toBe(3);
});
Jest is simple, relies on no third-party tools for most of its functionality, and requires zero configuration. After installation, you can start writing your tests immediately.
Component Tests Component tests assess the usability and behavior of individual components in an application. They are executed after unit tests and before integration tests. In this way, they help prevent bugs before they propagate to higher levels of testing.
Here’s an example component test using Jest and the React Testing Library :
import {cleanup, fireEvent, render} from '@testing-library/react';
import CheckboxWithLabel from '../CheckboxWithLabel';
// Note: running cleanup afterEach is done automatically for you in @testing-library/react@9.0.0 or higher
// unmount and cleanup DOM after the test is finished.
afterEach(cleanup);
it('CheckboxWithLabel changes the text after click', () => {
const {queryByLabelText, getByLabelText} = render(
,
);
expect(queryByLabelText(/off/i)).toBeTruthy();
fireEvent.click(getByLabelText(/off/i));
expect(queryByLabelText(/on/i)).toBeTruthy();
});
Snapshot Tests Snapshot tests ensure that your application’s UI has not changed unintentionally. They achieve this by comparing the current snapshot with the previous snapshot of a rendered component before and after code changes. A failing test can indicate two things: if the test results are unexpected, you need to resolve the issue with your component; if the test results are expected, you need to update your snapshot tests to support the new output.
Snapshot tests are useful to track changes in simple, stable components. If anything changes in these components, snapshot tests can immediately detect and minimize the regression efforts.
Following is an example snapshot test using Jest .
import renderer from 'react-test-renderer';
import Link from '../Link';
it('renders correctly', () => {
const tree = renderer
.create( Facebook)
.toJSON();
expect(tree).toMatchSnapshot();
});
Integration Tests Integration tests verify whether two or more units work together correctly after combining. These tests focus on the integrating links instead of the correctness of individual units that have already been tested. You only need to perform integration tests when your unit tests give a green light.
A passing integration test is a prior indicator of the correctness of the application’s interactions. If an integration test fails, you can take measures to fix it early on. This prevents the bug from slipping out to a higher level of testing and, therefore, reduces operational costs.
Here’s an example integration test, taken from the CSS-Tricks website :
test('successful login', async () => {
jest
.spyOn(window, 'fetch')
.mockResolvedValue({ json: () => ({ token: '123' }) });
render( );
const emailField = screen.getByRole('textbox', { name: 'Email' });
const passwordField = screen.getByLabelText('Password');
const button = screen.getByRole('button');
// fill out and submit form
fireEvent.change(emailField, { target: { value: 'test@email.com' } });
fireEvent.change(passwordField, { target: { value: 'password' } });
fireEvent.click(button);
// it sets loading state
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Loading...');
await waitFor(() => {
// it hides form elements
expect(button).not.toBeInTheDocument();
expect(emailField).not.toBeInTheDocument();
expect(passwordField).not.toBeInTheDocument();
// it displays success text and email address
const loggedInText = screen.getByText('Logged in as');
expect(loggedInText).toBeInTheDocument();
const emailAddressText = screen.getByText('test@email.com');
expect(emailAddressText).toBeInTheDocument();
});
});
End-to-End Tests End-to-end tests simulate end users and their interactions with the application. They cover user journeys from beginning to end and ensure all the integrated pieces of the application work as expected. End-to-end tests boost confidence in the overall functional correctness of the application.
They provide an indicator of the application’s production readiness. However, they are very slow to run because they involve building, deploying, opening a browser, navigating to the web page, and performing actions around the application. If an end-to-end test fails, it’s difficult to trace back the root cause because of the scope that it covers. They are hard to maintain and can be fragile.
End-to-end tests should complement other low-level tests and are not meant to replace them.
Following is an example end-to-end test using Cypress .
describe('My First Test', () => {
it('Gets, types and asserts', () => {
cy.visit('https://example.cypress.io')
cy.contains('type').click()
// Should be on a new URL which includes '/commands/actions'
cy.url().should('include', '/commands/actions')
// Get an input, type into it and verify that the value has been updated
cy.get('.action-email')
.type('fake@email.com')
.should('have.value', 'fake@email.com')
})
})
Cypress is simple to set up, requires no dependencies or downloads, and provides debugging capabilities. If you want an alternative to Cypress for end-to-end testing, you can check out Playwright and Selenium . Read more about Selenium in this post here .
Visual Regression Tests Visual regression tests scan for visual bugs (e.g. font, layout, color, and rendering issues) and verify the content of a web page by comparing screenshots taken before and after code changes. These tests are useful for applications that have graphical elements, such as tables, charts, and dashboards, since visual validation with traditional automated functional testing tools can be difficult.
Percy and Chromatic are some visual regression test tools. When you run a visual regression test, Percy takes screenshots of the UI across different browsers and responsive breakpoint widths. It then performs a pixel-by-pixel baseline comparison to detect and highlight any visual change.
Chromatic automates workflows for Storybook . Learn more about Storybook in this blog post here . As you publish a new Storybook, Chromatic takes screenshots and highlights any visual change.
Following is an example visual regression test using Percy and Cypress:
describe('Integration test with visual testing', function() {
it('Loads the homepage', function() {
// Load the page or perform any other interactions with the app.
cy.visit();
// Take a snapshot for visual diffing
cy.percySnapshot();
});
});
Replay Tests Replay tests, typically executed by a tool like Meticulous , determine whether there are any unexpected changes in the application’s workflow. As you manually interact with the application, the underlying tool captures your actions and converts them into tests. They require minimal effort to create, and you only need to structure your user flows to reap the benefits of these tests and provide a great convenience for validating end-to-end functionalities. You can even use Meticulous to record sessions on prod or dev, providing some testing with zero setup.
For more information check out Meticulous .
Accessibility Tests Accessibility tests are metrics to determine whether people with disabilities can use the application without any problems. An application with better accessibility widens its reach to a large audience. It’s important to incorporate these tests as you build your application and not as an afterthought.
Here’s an example accessibility test using Cypress and axe-core :
describe(‘Accessibility tests’, () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it(‘Has no accessibility violations’,() => {
cy.checkA11y();
});
});
Creating your Own Testing Pyramid As previously stated, a testing pyramid groups tests into three different layers, where unit, integration, and end-to-end tests occupy the bottom, middle, and top layers of the pyramid. The basic idea is that you should have high coverage of unit tests because they are cheap to build, easy to maintain, fast to run, and stable. Once tests, such as end-to-end tests, involve multiple units and interactions, they tend to become more expensive to build, hard to maintain, slower to run, and more brittle. For this set of tests you should aim for coverage of critical workflows, bearing in mind that you will almost certainly have a very limited number of end-to-end tests.
The testing pyramid referred to here should be used as a reference point as you think through structuring your own automated tests. It’s up to you to decide what types of tests are best suited to your application and in what proportion. For example, the testing pyramid suggests having few end-to-end tests in your test suite because of the maintenance burden. However, recent years have seen significant advances in tools that support end-to-end tests.
Replay tests , which are a form of end-to-end tests, ease the testing process to a great extent. With replay tests, you interact with the application manually, and the underlying tool records your actions and saves them as repeatable tests. You can then use these tests for regressions. Replay testing tools empower everyone on the team, from technical to nontechnical members, to contribute. They deliver more value since they precisely convert user actions to test steps.
Reiterating, you do not need to abide by the design decision made in the testing pyramid. Instead of a testing pyramid, your design may represent the testing trophy based on the priority you have set for your tests.
Conclusion In this article, you learned about the frontend testing pyramid, the different types of tests that you can incorporate into your application’s frontend, and the proportion of these tests that you can include in your automated test suite.
Meticulous Meticulous is a tool for software engineers to catch visual regressions in web applications without writing or maintaining UI tests .
Inject the Meticulous snippet onto production or staging and dev environments. This snippet records user sessions by collecting clickstream and network data. When you post a pull request, Meticulous selects a subset of recorded sessions which are relevant and simulates these against the frontend of your application. Meticulous takes screenshots at key points and detects any visual differences. It posts those diffs in a comment for you to inspect in a few seconds. Meticulous automatically updates the baseline images after you merge your PR. This eliminates the setup and maintenance burden of UI testing.
Meticulous isolates the frontend code by mocking out all network calls, using the previously recorded network responses. This means Meticulous never causes side effects and you don’t need a staging environment.
Learn more here .