Effective unit testing in the Frontend 🧪

Although this topic has been discussed several times, especially in recent years, after having several debates with colleagues, I’ve decided to write this post.

The goal is to share my perspective on how I think unit testing should be approached in frontend development (which aligns closely with Kent C. Dodds’ vision).

I don’t want to get into tooling, but I’ll use Jest with Testing library for the examples.

Yes, the Testing Pyramid Again 🙄

Many of us are familiar with this pyramid and have used it as a reference for our testing. In fact, it’s often our introduction to testing.

La pirámide de pruebas

The pyramid suggests that the foundation of our testing should be unit tests, supplemented by integration tests, and some e2e (end-to-end) or UI tests.

This method is efficient in terms of both development and execution costs. Logically, as we climb the pyramid, the resource requirement increases.

But.

Although it sounds good at first, we must remember that this pyramid was introduced around 2010. Since then, frontend development has evolved a lot, from being an interface for interaction with the backend to a huge entity of its own.

Timeline of Advances in Frontend Testing
Timeline of Advances in Frontend Testing
Timeline of Advances in Frontend Testing

Moreover, we now have much faster internet connections, offering an improved user experience, websites with more intense interaction with users, and more dynamism in general.

I’m not saying the pyramid is wrong, but we have to be careful when interpreting it, as basing our testing strategy on the 2009-2010 version can be potentially confusing.

The pyramid is a guide that has been revised over the years, not a strict rule.

What is a unit test in frontend? 👀

…Is it a UI component? A service method? A page? is the default should render correctly test? 🤨

To answer this, we must first consider the purpose of testing.

We test because we want to ensure that our product works as expected, without breaking existing functionality or blocking the user.

It’s hard to find a definition of unit test that fits all types of projects. A unit test in a component library, where our users are other developers, won’t be the same as in a marketplace, where the end-user has no technical knowledge.

For example, The Testing Trophy is a testing strategy popularized by Kent C. Dodds, where it is argued that the basis of our tests should be integration tests.

He classifies tests as follows:

  • “Unit tests are those that test units which either have no dependencies, or have dependencies mocked for the test.”
  • “Integration tests are those that check the integration of several units with each other.”

On the other hand, Martin Fowler uses the concepts solitary test for isolated unit tests, and sociable test for unit tests that do not mock their dependencies.

If you ask me for my definition of a unit test, I would say it aligns with the one used in TDD (Test Driven Development): a test covers a scenario or a functional requirement. Since we want them to be quick to execute and as stable as possible, we mock the backend.

Kent C. Dodds’ vision suggests that if we want to ensure that the user can use our application, testing an isolated component, with its dependencies (child components, services, etc.) mocked, involves some trade-offs.

…For example?

Practical Case of Unit Testing

Imagine we are asked to develop a login form for the /login page.

We create the Button and Form components:

// button.jsx
const Button = ({children, onClick}) => {
  return <button onClick={onClick}>{children}</button>
};

// form.jsx
const Form = () => {
  const handleSubmit = () => {
	  //	...
  }
  return (
    <form onSubmit={handleSubmit}>
      <Button onClick={handleSubmit}>Enviar</Button>
    </form>
  )
}

There are many ways to test this case. Let’s say we choose to create a test file for each element we need.

// button.test.jsx
it('should call onClick when clicked', () => {
  const onClick = jest.fn();
  render(<Button onClick={onClick}>Enviar</Button>);

  fireEvent.click(screen.getByText('Enviar'));

  expect(onClick).toHaveBeenCalled();
  expect(onClick).toHaveBeenCalledWith(...);
});

Later, we would perform another unit test for Form.jsx, for the services, etc., also with their dependencies mocked.

🔎 Disadvantages:

1. The tests do not reflect how our application is used.

This approach tends to narrow the focus of the tests too much, leading us to focus on implementation details: aspects that are not relevant to the user.

The end user doesn’t care that pressing a button calls a method, or that one component passes certain props to another. The user cares that by pressing a button, they receive feedback.

We focus on making assertions about technical requirements and pay less attention to testing functionality in complete scenarios, where it is verified that all elements work in harmony.

2. Friction when refactoring.

There’s another big risk: if we want to refactor our component, it could create friction in the tests.

For example, if we change the name of Button to Botoncito, or modify the name of the onClick prop to onClickButton, the test in button.test.js would fail, even though the business logic remains intact.

Okay, I understand that testing isolated components is not the most efficient, but then…

How Should We Test the Frontend? 🤔

First, we need to understand that the frontend is a user interface. And as such, we should test it as a user would use it.


That is, users don’t manually run the getListOfLittleThings() service, or press a button unless they are in a specific scenario. Instead, they interact with elements on our page, which in turn call other services.

This means that instead of testing a service in isolation, it would be more reliable to trigger the execution of the service through interaction with the DOM.

The other big question is, how much does such a test cover?

The answer is that you can use different strategies. This part will depend on your architecture and your needs.

In this case, we render the /login page and focus the test on meeting a specific acceptance criterion.

// login.test.jsx
describe('Given the user navigates to the login page', () => {
  describe('when the user clicks on "Enviar" button', () => {
    it('should display "Eureca!" message', async () => {
      render(<LoginPage />);

      const buttonForm = screen.getByText('Enviar');
      await fireEvent.click(buttonForm);

      const successMessage = screen.getByText('Eureca!');
      expect(successMessage).toBeVisible();
    });
  });
});

What do we gain from this?

  1. 🛡 More confidence in the tests: The tests reflect how users actually use the application.
  2. Less friction when refactoring: The tests do not focus on isolated components, but on user flows, avoiding testing implementation details.
  3. 🌈 More coverage: The tests do not focus on testing a single expect, instead, they cover much more code with fewer tests.
  4. 📚 Better test descriptions: The descriptions of our tests better reflect the business logic.

However, this does not mean that isolated unit tests are forbidden, far from it.

For example, for shared libraries or very complex logic, I would consider conducting isolated unit tests.

The whole other story of mutation testing, e2e, performance testing… let’s leave that for another day. 🥸

Conclusions

In the world of frontend, our testing approaches need to be as flexible and responsive as the applications we build. Our objective shouldn’t be limited to creating functional tests; we should aim for tests that genuinely echo the user’s experience.

When we focus on testing isolated components, we often get stuck on implementation details, which can result in fragile tests that are sensitive to any changes in the code.

So, the next time you write a test, ask yourself: “Does this reflect how a real user would use this?” If the answer is yes, you’re on the right track.

🧪 Happy testing! 🧪

📕 References

  1. The Practical Test Pyramid
  2. Static vs Unit vs Integration vs E2E Testing for Frontend Apps
  3. Deconstruyendo la pirámide de los tests
  4. Unit Testing is Overrated