Published on February 4, 2024
On UI Unit Tests and React
No unit tests, Unit test everything or what?
Why do we test or who benefits from tests?
Yur users don’t care about tests, whether they are passing or failing. What creates value for the user is a working system, that’s what they come to us for. So if not the user who else? You, the developer! The one who has to deliver a working product or fix it if it’s failing.
With this in mind, it’s easy to justify the case for front-end unit tests. If you want more bang for the buck, end-to-end tests seem to be the right tool for testing the UI. But once you shift your perspective to the developer as the main benefactor, unit tests become more critical.
But the biggest benefit isn’t about merely avoiding production bugs (or enabling developers to fix them), it’s about the confidence that you get to make changes to the system. — Martin Fowler
Compared to other types of tests, unit tests have much to offer to the developer experience:
- Finding the root cause for a failing end-to-end test is painful and can take a long time. A failing unit test usually points to the root cause since you are testing a “unit”.
- It’s not unusual for a bigger bug to uncover multiple smaller bugs. Unit testing ensures that bugs don’t pile up.
- End-to-end tests can be flaky at times, a rare behavior in unit tests.
Including unit tests in our development flow, trains us to deliver “clean and maintainable code” that will be easy to refactor when that inevitable moment arrives.
How to write “unit-testable” React code?
First of all, testing has to be part of your development process. If you don’t add tests in the development, you most likely won’t add them. This is more true when working on fast-paced projects, there is always a new task in the pipeline for you to find time to revisit production code and add unit tests.
The code should be atomic. Atomic === “unit” in unit test. Unit tests were conceived for testing small functional units, not full pages or highly composed components. In the React world, this translates to applying the single responsibility principle: build small components with standard interfaces that only have one reason to change, separate presentation & application/business logic by using custom hooks & utility functions, and have clear input and outputs.
Complexity is the enemy of testability.
What should be tested?
Everyone knows how to test, or at least it is easy to find out. But what should we be unit-testing?
Rule 0 -
Don’t reflect your internal code structure within your unit tests. Test the interface, not the implementation. Test for observable behavior, consider “If I enter values x and y, will the result be z?”
Rule 1 -
Test control flow (and consequentially data flow). Ensure the right things are rendered at the right time. This is for functions and components with conditionals. (changes in context, props, subscriptions, hooks)
Rule 2 -
Test the response to user events - again think about observable behaviors and function output not (internal) implementation details. The best way to achieve this is to test the interface, buttons’ onClick, field’s onChange
Rule 3 -
Test validation logic and data transformations. This is where React’s custom hooks shine. By extracting the component’s application logic into custom hooks it becomes very easy to unit-test. Applies to functions that determine how data should be created, exchanged, and managed.
Rule 4 - Don’t unit test
- Multi-step forms and wizards
- Controllers (best suited for integration & e2e tests)
- Constants
- Internal component state
- Lifecycle events/methods
- UX components (components without props or subscriptions)