Unit Testing: The Safety Net aka How to Sleep at Night
100% Code Coverage is a vanity metric. How to write meaningful Unit Tests with Jest that catch real bugs without preventing refactoring.
The Confidence Crisis
You need to refactor a core function.
Maybe it is the calculateTax() function in the checkout.
It is messy. It has nested if statements. It uses old syntax. You want to clean it up.
But you are terrified.
You ask yourself: “If I touch this, and I break a specific tax rule for German wholesale customers, will I know?”
If the answer is “No”, you are paralyzed.
So you leave it. The “Legacy Code” remains. It rots. It becomes the “Scary Folder” that nobody touches.
Unit Tests are the cure for this paralysis.
They freeze the behavior of the code in time.
They guarantee: “This function, given input 2, MUST return 4.”
If that contract holds, you can rewrite the internal implementation however you want.
Tests facilitate Aggressive Refactoring. Without tests, refactoring is just “guessing”.
Why Maison Code Discusses This
At Maison Code, we often perform “Rescue Missions” on legacy platforms. We find codebases where developers are afraid to deploy. “Don’t touch the UserSync module, it breaks if you look at it wrong.” This fear paralyzes the business. New features take months because 80% of the time is spent on manual regression testing. We believe that Testable Code is Clean Code. We implement robust Unit Testing suites using Jest/Vitest to give the team their confidence back. When the tests are green, you deploy. Simple as that. We turn “Deployment Friday” from a nightmare into a non-event.
The Tool: Jest / Vitest
- Jest: The incumbent. Maintained by Meta. Standard in Create React App / Next.js. Powerful but heavy.
- Vitest: The challenger. Powered by Vite. It is functionally identical to Jest (compatible API) but 10x faster because it uses ES Modules natively. We will use Jest syntax here, as it applies to both. The concepts are universal.
The Strategy: What to Test?
This is where teams fail. The “100% Coverage” Trap. Managers crave “100% Code Coverage”. They put it in the OKRs. This leads to useless tests.
Bad Test (Testing The Framework):
const Button = ({ label }) => <button>{label}</button>;
test('Button renders', () => {
render(<Button label="Go" />);
expect(screen.getByText('Go')).toBeInTheDocument();
});
This test has Low Value. It is testing React. We know React works.
It has High Maintenance. If we rename label to text, the test breaks, even though the app works. This is a “False Negative”.
Good Test Strategy: Focus on Business Logic and Edge Cases.
1. Pure Functions (Highest ROI)
A pure function depends only on arguments and returns a value. No side effects. No API calls. These are a joy to test. They run in milliseconds.
// logic.ts
/**
* Calculates discount based on customer tier.
* Rules:
* - VIP gets 20% off.
* - Negative price throws error.
* - Employee gets 50% off.
*/
export function calculateDiscount(price: number, type: 'VIP' | 'Regular' | 'Employee'): number {
if (price < 0) throw new Error('Negative price is impossible');
if (type === 'Employee') return price * 0.5;
if (type === 'VIP') return price * 0.8;
return price;
}
// logic.test.ts
describe('calculateDiscount', () => {
test('gives 20% off to VIP', () => {
// Arrange
const price = 100;
const type = 'VIP';
// Act
const result = calculateDiscount(price, type);
// Assert
expect(result).toBe(80);
});
test('gives 50% off to Employee', () => {
expect(calculateDiscount(100, 'Employee')).toBe(50);
});
test('gives 0% off to Regular', () => {
expect(calculateDiscount(100, 'Regular')).toBe(100); // 100 * 1 = 100
});
test('throws on negative price', () => {
// Note: We wrap the calling code in a function so Jest can catch the error
expect(() => calculateDiscount(-10, 'Regular')).toThrow('Negative price');
});
});
This is robust. It documents the business rules better than comments.
2. Mocking (The Necessary Evil)
Most code interacts with the world (Database, API, LocalStorage). You cannot run a real API call in a unit test. It is slow, flaky, and requires credentials. You Mock the dependency.
// userProfile.ts
import { fetchUserFromStripe } from './api';
export async function getUserStatus(id: string) {
const user = await fetchUserFromStripe(id); // External dependency
if (user.delinquent) return 'Blocked';
return 'Active';
}
// userProfile.test.ts
import { fetchUserFromStripe } from './api';
jest.mock('./api'); // Auto-mock the module. Replaces real functions with jest.fn()
test('returns Blocked if user is delinquent', async () => {
// Setup the mock response
(fetchUserFromStripe as jest.Mock).mockResolvedValue({ id: '1', delinquent: true });
const status = await getUserStatus('1');
expect(status).toBe('Blocked');
// Verify api was called correctly to ensure we pass the ID
expect(fetchUserFromStripe).toHaveBeenCalledWith('1');
});
Warning on Mocks: Mocks lie.
If the real fetchUserFromStripe changes its return format (e.g. delinquent becomes isDelinquent), your test will still pass (because you mocked the old format), but your app will crash.
Use TypeScript to keep mocks in sync with real types.
Snapshot Testing: Feature or Bug?
Jest introduced “Snapshots”.
expect(component).toMatchSnapshot().
It serializes the rendered component to a text file (__snapshots__/comp.test.js.snap).
If you change the H1 to H2, the test fails.
The Problem: Developers treat this as nuisance.
They run the test. It fails. They type jest -u (Update) without looking.
Snapshot tests are Brittle.
Best Practice: Use Snapshots for very small, stable components (Icons, Design Tokens) where any change is suspicious. Do not use them for complex Pages.
Testing Async Code (The Async/Await Trap)
Testing Promises is tricky.
Common mistake: forgetting await or params.
// BAD: Test finishes before the Promise resolves
test('bad async test', () => {
fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
If fetchData fails, the test might still pass (false positive) or timeout.
Good:
test('good async test', async () => {
const data = await fetchData();
expect(data).toBe('peanut butter');
});
For timers (e.g. setTimeout), use Fake Timers:
jest.useFakeTimers().
jest.advanceTimersByTime(1000).
This allows you to test a 10-second delay in 1 millisecond.
The TDD (Test Driven Development) Debate
“Write the test first.” Pros: Forces you to design the API before implementation. Results in very clean, decoupled code. Cons: Slower initial velocity. Hard when you are “exploring” (prototyping) and don’t know the final structure. Verdict: Use TDD for complex algorithmic logic (e.g., Parsing a CSV, Calculating Tax, String manipulation). Don’t use it for simple UI components where you are iterating on pixels.
Integration vs Unit: The Trophy Shape
Kent C. Dodds formalized the “Testing Trophy”.
- Static Analysis (ESLint, TypeScript): Catches typos. (Fastest/Cheap).
- Unit Tests: Test pure functions. (Fast).
- Integration Tests: Test the connection between components. (The Sweet Spot).
- E2E Tests: Test the full browser. (Slow/Expensive).
Recommendation: Write mostly Integration Tests.
Test LoginForm + SubmitButton + ApiMock.
Don’t test SubmitButton in isolation.
Test that “Clicking Submit calls the login API”.
FAQ
Q: How do I test Private functions? A: Don’t. Test the Public API. The private function is an implementation detail. If you test private functions, you lock yourself into that implementation. You lose the ability to refactor.
Q: CI/CD Integration?
A: Tests must run on every Pull Request.
If npm test fails, the Merge Button should be disabled.
This maintains the “Green Master” policy.
Conclusion
Tests are an investment. You pay upfront (Time). You get dividends forever (Stability, Speed of Refactoring, Documentation). A codebase without tests is a “Legacy Codebase” from day one. A codebase with tests is an “Asset”. It appreciates in value.
Implementation Paralyzed?
If your team is paralyzed by fear of breaking legacy code, Maison Code is the solution. We perform “Refactoring Raids” where we audit your codebase, add test coverage, and clean up the tech debt that is slowing you down.
Codebase fragile?
We refactor legacy codebases to be testable and implement Jest/Vitest runners. Hire our Architects.