A philosophy of frontend testing: testing interactions with the outside world
Don't mock side effects in unit tests: instead, push interactions with external systems at the boundaries.
It's no surprise that test-first thinking leads to better software design, and hopefully to fewer bugs. It also forces us to think in terms of independent, interacting parts, rather than "wholes".
"How can I not couple this component to its data fetching logic?" This kind of thought process should happen all the times especially when writing frontend code, which by definition needs to interact with external systems, thus with the outside world, all the time.
Without enough discipline, sometimes we are naturally inclined to write tightly coupled code, and this happens especially when we are under pressure to ship.
Example of external systems in the context of frontend code are, for instance:
- routing
- DOM APIs
- storages (session and local storage)
- REST and GraphQL APIs
and many more.
When writing frontend code, and in general any kind of code, we always need to strive for loosely coupled units, as much as possible independent of each other, and more important, independent of any external system.
Easy to say, but how does this look in practice?
Let's see a practical example by examining the quintessential form component.
Disclaimer: the example is presented in React as its component model is the most intuitive and straightforward to understand.
Example: a coupled form
Most of the time, HTML forms need to do the following things:
- validate any input
- gather all the submission data
- handle the submission
Here's a rather dummy example of a form component in React:
const Form = () => {
const handleSubmit = async ev => {
ev.preventDefault()
try {
// Make your API call here
} catch (err) {
// Handle the error
}
}
return (
<form onSubmit={handleSubmit}>
<input type='text' id='name' name='name' />
<button type='submit'>create stuff</button>
</form>
)
}
What should we test here, in this specific case where the form is also directly making the API call? Let's see:
- any validation needs to be tested
- inputs and corresponding labels
- the actual API call
However, if we try to put this component under a unit test, that's not a unit test anymore. It's more of an integration test, and this means we need to set up a mock for Fetch
, axios
, or whichever library we're using.
I think that there is little or no value in mocking XHR calls in unit tests. The same concept applies to any DOM APIs: mocking these APIs is most of the time a waste of effort.
Mocks are noisy, they can quickly go out of sync, and more important, leaving business logic inside components still feels like a smell, not counting that tools like Redux strongly encourage to keep business logic exactly away from components.
So, what needs to be done here in order to cleanly test this component?
A better approach: move interactions with external systems at the boundaries
Let's step back for a minute before writing the actual unit test for this form, in order to identify where the boundary with the outside world is.
In my mind, the boundary is the touch point where the interaction with the external system (the API in this case) is so far away from any component at the unit level, that we don't need to set up any mock for the XHR library.
For example, in JavaScript server-side frameworks like Remix or Next.js, the boundary is the page.
To put it short, to move interactions with external systems at the boundaries, we simply delegate the submission handling to the outside world with a simple handleSubmit
prop:
const Form = ({ handleSubmit }) => {
return (
<form onSubmit={handleSubmit}>
<input type='text' id='name' name='name' />
<button type='submit'>create stuff</button>
</form>
)
}
It doesn't matter where this form is placed in the component tree, as long as handleSubmit
is handled at the boundaries of the system.
The rule here is: if you need to mock an API call in a component unit test that means the component is too tightly coupled with the external system.
Again, what should we test here, in this specific case where the form is not directly making the API call? Let's see:
- again, any validation needs to be tested
- inputs and corresponding labels
handleSubmit
invocation
To test these cases we don't need to set up any mock for the XHR library. Testing the handleSubmit
call is easy as setting up a mock function and asserting that it's called at submit.
Advantages
What are the advantages of this approach? For one, you can test the "whole" with a functional test, by also setting up a stub instead of a mock. This is easy with tools like Cypress intercept.
You might argue: "yes but with a stub I won't be able to test corner cases". I'd say: corner cases are not meant to be tested at the functional level. There are unit tests for that, meaning that you extracted the logic in a way that it could be properly tested in a unit test.
Key takeaways
Any logic interacting with the outside world must be pushed at the boundaries of the application.
- identify the boundaries of the given component
- push interactions with external systems at the boundaries. For example, in JavaScript server-side frameworks like Remix or Next.js, the boundary is the page