Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

How to write better components unit tests

So, you're already writing unit tests for your components. Maybe you're even practicing TDD because you want your tests to improve your production code design. You understand that creating a test suite that inspires confidence in changing production code is challenging.

I want to share with you what can be done to your components unit tests to maximize their utility. I'm focusing on common issues in test suites that I see repeated in different code bases.

When it comes to my advice on the process of writing tests, it's difficult not to end up talking about TDD. Although TDD is a subject of many heated discussions, I stick with TDD. Why? Because writing tests post-implementation doesn't give me the chance to influence code design with tests. This is why, for me, it's all about the process. The rest of the benefits that come from well-written tests is the same for pre or post-implementation: fewer bugs, derisking regression, inspiring the confidence of changing production code.

The top-down or bottom-up approach

The bottom-up approach is to start with the small units first. This is a typical Detroit-style TDD, and it's not my favorite, at least not for how I approach components. The danger of starting small is YAGNI. In my experience, we're not very good at making predictions. You might not need all of the small units in your final solution that may deviate from the initial idea. It's not only about wasted time on developing something you didn't need but also integration issues like discovering additional requirements late, mismatched parameter types, mismatched return types.

The top-down approach is to start with the high-level component first. This is more aligned with London-style TDD. It allows for gradually discovering units. I find this approach more natural when practicing DDD too. Designing an initial high-level API for the first unit test on the container component allows starting modeling required domain object interfaces, defining shared types. This is going to make the integration part easier.

The discovery process of the top-down approach fits well with functional programming. After defining the initial public API, the discovery process consists of looking for the smallest units that when composed can fulfill the feature requirements.

Integration tests or collaboration tests

Part of designing the test suite is a team agreeing on some core design principles. One of them is the common understanding of whether container components tests are integration or collaboration tests. What's the difference?

Integration tests do not mock container component dependencies or mock only the minimal amount to eliminate side effects. They have a high regression value, but the trade-off is redundant code coverage.

Redundant coverage is when code depends not only on its symmetrical unit tests and end-to-end test but also on its parent(s) integration tests. Redundant code coverage means that developers will experience more false negatives and waste time on updating other tests. It might be counterintuitive, but this high number of integration tests will undermine the belief in the usefulness of the test suite.

Collaboration tests trade regression value for avoiding redundant code coverage. Collaboration test mocks dependencies of the container component and only tests how the container component collaborates with its dependencies. Collaboration tests can be more demanding to read and understand due to many mocks.

Extract refactors

For me, extract refactorings are a valuable benchmark for how well the test suite is doing its job and information about the degree of tests couping. When you want to reuse a piece of code or code that becomes complex enough to be worthy of a separate function or module, you perform extract refactoring.

The tricky part is that the new unit has been tested already via its parent tests. After extraction, the symmetrical unit test should be created, ideally by mostly copying the old tests from the parent. Parent tests shouldn't be failing. Parent test cases that duplicate behavior and logic in a symmetrical unit test aren't needed as it's better to avoid redundant coverage.

Custom render function

Testing components often requires a little bit of a setup, providing additional props, wrapping in providers, etc. This duplication for each test case harms tests readability and introduces more work in the case the public API (component props) changes. In unit tests, like in production code, reusable functions are vital tools in addressing duplication.

import { render, screen } from '@testing-library/react';
import { SignUpForm, SignUpFormProps } from "./SignUpForm";

function renderSignUpForm(props: Partial<SignUpFormProps> = {}) {
 const onSubmit = jest.fn();
 render(
     <SignUpForm scope="email" error={null} onSubmit={onSubmit} {...props} />
 );
 return { onSubmit };
}

describe("SignUpForm", () => {
   it("allows to sign up with Gmail", () => {
        const { onSubmit } = renderSignUpForm({ scope: "oauth" });
        // ...
   });
});

Custom render function reduces setup work to a minimum and provides reasonable defaults. When the test case specifies a different prop, it signals this is relevant for this particular test case.

Arrange-Act-Assert (AAA)

Arrange-Act-Assert is a simple testing pattern that provides three phases for structuring a single test case. AAA introduces clarity and makes a test case easier to understand. Another name for AAA, more typical for BDD frameworks, is Given-When-Then. Test case separates each phase by a new line.

describe("SignUpForm", () => {
   it("allows to sign up with email", () => {
       const { onSubmit } = renderSignUpForm({ scope: "email" });


       userEvent.type(
           screen.getByRole('textbox', { name: "Email" }), '[email protected]');
       userEvent.type(
           screen.getByRole('textbox', { name: "Password" }), 'password123');
       fireEvent.click(
           screen.getByRole('button', { name: "Sign up" }));


       expect(onSubmit).toHaveBeenCalledWith({
         email: "[email protected]",
         password: "password123",
       });
   });
});

Sometimes I came across test cases with only Act and Assert phases with Arrange phase delegated to a before the callback and introduced shared state. Developers very eager to use nested describes can write a test case with only a single Assert. The Act phase happens during the preceding before callback. I encourage you not to do that and keep the test code easy to understand with a flat structure. Keep the test runner's life cycle callbacks for environment setup and cleanup.

Having heuristics

This one comes with experience, but when you and your team design test and production code, you need some heuristics that allow you to work efficiently and make quick decisions.

  • I prefer a top-down approach to unit tests because I follow the YAGNI rule very often. YAGNI also fits my pragmatic attitude to coding.
  • I prefer simplicity of the design. I follow KISS and try to avoid unnecessary complexity when possible.
  • I prefer duplication over the wrong abstraction (-Sandi Metz). Unless I'm confident the abstraction is correct, I'm more likely to copy a piece of code instead of brute force my way with extract refactorings. Code with fewer callers is easier to rewrite.

There are many more, but think about them as values you and your fellow programmers share. Discuss them openly and regularly evaluate your test suite design against them.

Photo by Shane Aldendorff on Unsplash.