Unit Tests: Fast Diagnosis Near the Code
- date
- category
- Testing
- reading
- 4 min / 762 words
The simplest bug in code should not wait for staging.
If a function calculates a discount incorrectly, a validator accepts an empty email, or a mapper drops one field, the cheapest place to catch the bug is right next to the code.
That is where the unit test belongs.
What kind of test this is
Unit test checks a small unit of behavior in isolation from the rest of the system.
The unit does not have to be one function.
It can be:
function
method
class
small module
reducer
validator
mapper
policy object
What matters is not the filename, but whether the test has a short distance to the decision in code.
If the assertion fails, it should be clear which condition, branch, or mapping stopped working.
A unit test should not need a real database, network, message broker, browser, or the whole application framework.
It may use a mock, stub, fake, or fixture, but only when that makes the boundary easier to see.
If half of the test describes a fake world around the code, the unit may be poorly chosen, or the code may be too tightly coupled to its dependencies.
What a unit test does not prove
A unit test does not say the system works.
It says:
this decision in code gives the expected result for this input
That is a small promise, but a valuable one.
Without unit tests, simple logic bugs escape upward.
Then an E2E test can fail because one function rounded a number incorrectly.
Diagnosis starts with a browser, request, user fixture, and logs, even though the fix lives in three lines of code.
A unit test shortens that loop.
Isolation is not testing implementation
The worst unit test knows too much about the inside.
It checks that a private method was called twice.
It couples itself to the order of steps that are not part of behavior.
It breaks after a refactor, even though the result is still correct.
A better unit test looks at the input and output of the unit.
input data
initial state
call to the public function
expected result
If an interaction with a dependency must be checked, the test should check the contract of that interaction, not every movement inside the function.
For example: whether the code sends an OrderPaid event, not whether it first called buildPayload(), then serialize(), then publish().
Mocks, stubs, and fakes
A mock is sometimes necessary, but it can easily turn a test into a record of implementation.
In practice it helps to separate a few simple things.
Stub returns a prepared response.
Fake is a simple implementation of a dependency, for example an in-memory store.
Mock usually checks whether a dependency was called in a specific way.
The more mocks a test has, the more often it describes connections between objects instead of behavior.
That does not mean mocks are bad.
It means we need to watch where the test's value is.
If a mock-based test catches a change in a public decision, it is useful.
If it only catches a change in the order of private steps, it will get in the way of refactoring.
Unit test as a regression test
A good unit test often appears after a bug.
First there is a symptom:
a user with a 100% coupon receives a negative price
Then the fix.
Then a unit test that closes exactly that case.
At that point the unit test is also a regression test.
The name unit says something about the level of isolation.
The name regression says why the test exists.
These are different naming axes, and it is worth keeping them separate.
Tool from the stack
For unit tests in this stack I would choose Vitest.
It fits TypeScript, React, and Node backend code, so both sides of the application can use the same working model.
On the frontend it works well for helpers, reducers, validators, and hooks that do not require full component rendering.
On the backend it checks domain rules, parsers, mappers, policy objects, and small services without starting Fastify or a database.
If the project already uses Jest, I would not rewrite tests just for the tool name.
The mechanism matters more than the runner.
When a unit test is too small
A unit test stops being enough when the risk lives at a boundary.
It will not check whether SQL matches the database schema.
It will not check whether a serializer and parser understand the same format.
It will not check whether the application has the middleware wired correctly.
It will not check whether a transaction covers all writes.
Then the next test type is needed.
Not because the unit test was bad.
Because it answered its own question and reached a boundary it cannot see by definition.
The next layer is component tests.