Frontend: To Test or Not to Test?
- 10 min readNumerous are the tools designed for testing the frontend of our applications,
Numerous are the 'tests shaman' (ref. grugbgrain) promoting the benefits of testing in every project in every place.
Yet, numerous are the developers who don't want to test their frontend.
Why? What is the source of this paradox?
Testing plays a crucial role in preventing regressions, ensuring that our code functions as intended, and providing us with the confidence needed for modifications and enhancements. Additionally, making our code testable not only assists in its documentation but also positively enhances its structure, encouraging the decoupling of its various components.
However, in frontend development, the complexity of handling UI components, which lack clear function signatures (or contract) and are browser-dependent, makes writing these tests both complicated and often fragile...
As a result, the cost/benefit ratio of testing often turns out to be negative, which explains why frontend developers are hesitant to write them.
Nonetheless, it would be unfair to stop at this conclusion as not all types of tests are equal. Let's take a look at the different types of tests and their benefits.
Types of tests
1. Unit tests
According to software development experts like Kent Beck and Robert C. Martin, unit tests are designed to cover use cases (or specifications) of an algorithm, at the level of a function or a class. They adhere to the F.I.R.S.T principle, which stands for Fast, Isolated, Repeatable, Self-validating, and Timely.
Here is an example of a unit test using Jest:
_13import sum from "./sum";_13_13it("should be 1 when 1 + 0", () => {_13 expect(sum(1, 0)).toBe(1);_13});_13_13it("should be 12 when 5 + 7", () => {_13 expect(sum(5, 7)).toBe(12);_13});_13_13it("should adds properly two negative numbers", () => {_13 expect(sum(-1, -2)).toBe(-3);_13});
When to use ?
- When you have a clear contract (function signature)
- When you have an algorithm that can be tested in isolation
Example of use cases:
- Data formatting
- Helper functions
- Computing functions
- State machines
Pros:
- Fast to execute
- Easy to write
- Easy to maintain
Cons:
- Not adapted to UI Component (because you need to mock dom)
- Does not appear a lot in the frontend world
Tips:
- Use dependency injection to avoid mocking
- Use test-driven development to solve a complex algorithm thanks to the baby steps approach
1.2. Integration tests
There is no clear definition of integration tests. Some people consider them to be a subset of unit tests, while others consider them to be a part of component testing.
In my perspective, integration testing is a blend of unit testing and the use of mocking tools. It is particularly relevant when you need to test the interactions between two (or more) components, or between a component and an external dependency. Integration tests use mocks to effectively simulate and test all use cases.
Here is an example of an integration test using react-testing-library:
_20import { fireEvent, render, waitFor } from "@testing-library/react";_20import App from "../App";_20_20jest.mock("../api/users", () => ({_20 fetchUsers: jest.fn().mockResolvedValue([_20 { id: 1, name: "John Doe", details: "Some details about John" },_20 { id: 2, name: "Jane Doe", details: "Some details about Jane" },_20 ]),_20}));_20_20it("should displays details in UserDetail when selecting a user in UserList ", async () => {_20 const { getByText, findByText } = render(<App />);_20 // Wait for the users to be displayed_20 const user = await findByText("John Doe");_20 // Simulate selecting a user_20 fireEvent.click(user);_20 // Wait for and check if the user details are displayed_20 const userDetails = await findByText("Some details about John");_20 expect(userDetails).toBeInTheDocument();_20});
When to use ?
- When you need to test the interactions between two (or more) components
- When you need to test the interactions between a component and an external dependency
- When you need to mock something to test a component
Example of use cases:
- Component use External API
- Component use LocalStorage or global state
Pros:
- Fast to execute
- Good mocking tools ecosystem
- Do not need a browser
- Easy to integrate in CI/CD
Cons:
- Mock is fragile
- When a test fails, it is not always easy to understand why
- Cannot tests Canvas, WebGl, video etc.
- Do not use browser so it's not a real user test
- Cannot test accessibility, responsive design, visual regression etc.
Tips:
- Do not overuse them (I don't use them at all)
- Do not use DOM (use data-testid instead or Text search)
1.3. Component tests (the most relevant one)
Like integration tests, the definition is not clear. Some people consider them to be integration tests.
In my view, and in the perspective of tools such as Storybook, Cypress, and others, component tests are designed to assess the functionality of a single component in a standalone environment within a browser
Example of a component test using Storybook without interactions (for atoms/molecules):
_27const meta: Meta<typeof TimerCircle> = {_27 title: "features/todo-today/molecules/TimerCircle",_27 component: TimerCircle,_27 tags: ["autodocs"],_27};_27_27export default meta;_27_27type Story = StoryObj<typeof TimerCircle>;_27_27export const Full: Story = {_27 args: {_27 percentage: 100,_27 },_27};_27_27export const Half: Story = {_27 args: {_27 percentage: 50,_27 },_27};_27_27export const Empy: Story = {_27 args: {_27 percentage: 0,_27 },_27};
Example of a component test using Storybook with interactions (for complex organisms):
_34const TASK_EXAMPLE = "Go to the gym";_34_34const meta: Meta<typeof TodoToday> = {_34 title: "features/todo-today/pages/TodoToday",_34 component: TodoToday,_34 tags: ["autodocs"],_34};_34_34export default meta;_34_34type Story = StoryObj<typeof TodoToday>;_34_34export const Prototype: Story = {_34 args: {},_34 play: async ({ args, canvasElement, step }) => {_34 const canvas = within(canvasElement);_34_34 await step("Create new task", async () => {_34 const addTaskButton = await canvas.findByText("Add Task");_34 await userEvent.click(addTaskButton);_34_34 const taskCreated = await canvas.findByPlaceholderText("Enter a task");_34 await userEvent.type(taskCreated, TASK_EXAMPLE);_34 expect(taskCreated).toHaveTextContent(TASK_EXAMPLE);_34 });_34_34 await step("Move task to parking", async () => {_34 const goToParkingButton = await canvas.findByTestId("go-to-parking-button");_34 await userEvent.dblClick(goToParkingButton);_34 const parking = await canvas.findByTestId("parking");_34 expect(parking).not.toHaveTextContent("0");_34 });_34 },_34};
When to use?
- On every UI component
Example of use cases:
- atoms, molecules, organisms, etc.
Pros:
- Fast to execute
- Isolated testing
- Real browser testing
- No mock
- Easy to write
- Easy to maintain
- Easy to find the bug when a test fails
- Force you to create reusable components
- Document and showcase your components for every team member that join projects
- Can test accessibility, responsive design, visual regression etc.
Cons:
- Syntax not user friendly for beginners
- Need to learn a new tool
- You will be storybook dependent
Tips:
- Use Smart/Dumb pattern to make the component parametrable
- Use interactions only on complex component (like organisms)
- Use them often
- Cypress component test is an alternative to Storybook
- In my opinion the most relevant type of tests for frontend development!
- Do not use DOM when using interactions (use data-testid instead or Text search)
1.4. Visual tests
Visual testing is a technique that allows you to compare a pixel by pixel regression. It is usefull to detect visual regression.
When to use?
- When you have critical components
- When you already used component testing
Pros:
- Online free tools manage it for you (Chromatic, Applitools)
- Easy to integrate in CI/CD
- No configuration, no code to write
Cons:
- Not adapted to animated background
- Not adapted to video
- Can be overkill
1.5. End-to-end tests
End-to-end tests are designed to test the entire application, from the user interface to the database. They are often used to test the critical paths of an application.
Example of an end-to-end test using Cypress:
_11describe('E2E Test Using data-testid', () => {_11 it('Interacts with elements using data-testid', () => {_11 // Replace with your web application's URL_11 cy.visit('https://example.com');_11_11 // Click a button using data-testid_11 cy.get('[data-testid="submit-button"]').click();_11_11 // Assert if a specific text is present in an element with a data-testid_11 cy.get('[data-testid="message-box"]').should('contain', 'Welcome to the site!');_11 });
When to use?
- When you need to test the entire application (multiple pages, etc.)
- When you need to test the critical paths of an application
Example of use cases:
- A settings change that affects the entire application
- A critical process
- A page with too much external dependencies
Pros:
- The most realistic test
- Can test complex interactions
- Easy to find the bug when a test fails
Cons:
- Costly to execute
- Costly to write
- Fragile
- Login problematic
Tips:
- Do not use them a lot because they are not refactor friendly
- Use them only on critical path
- Make Smoke test/Click test (only test the critical path the simplest way possible)
- Do not use DOM when using interactions (use data-testid instead or Text search)
Conclusion
As you can see, there are many types of tests, each with its own pros and cons. It is therefore important to choose the right type of test for the right situation.
For me, the most relevant type of tests for frontend development are component tests. They are fast to execute, easy to write, easy to maintain, and easy to find the bug when a test fails. They also force you to create reusable components, document and showcase your components for every team member that joins the project, and can test accessibility, responsive design, visual regression, etc.
I hope this article has helped you to better understand the different types of tests and their benefits. If you have any questions or comments, please feel free to contact me on Twitter.
Al1x-ai
Advanced form of artificial intelligence designed to assist humans in solving source code problems and empowering them to become independent in their coding endeavors. Feel free to reach out to me on X (twitter).