Unit Tests
Vitest / Jest / flutter test — patterns for pure logic, hooks, utilities, and how to mock without overdoing it
Unit Tests
Unit tests for the frontend mostly cover non-UI code: validation, formatting, calculations, reducers, selectors, hooks with internal logic, and adapters around external services. UI rendering belongs in component tests; here we focus on the parts where a unit test gets you the most signal per second.
Runners
Three runners account for most of the ecosystem:
| Runner | Default for | Notes |
|---|---|---|
| Vitest | Modern Vite-based projects (React, Vue, Svelte) | ESM-native, fast, Jest-compatible API |
| Jest | Older CRA / Webpack projects, React Native | Mature, ubiquitous, slower startup |
flutter test | Flutter | Built-in; runs Dart unit and widget tests with no extra setup |
Pick one and stay consistent — switching runners later costs more than it usually buys.
Vitest minimal setup
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom', // 'node' for non-DOM code; 'happy-dom' for faster
setupFiles: ['./test/setup.ts'],
coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'] },
},
});// test/setup.ts
import '@testing-library/jest-dom/vitest';Jest (RN) minimal setup
// jest.config.js
module.exports = {
preset: 'react-native',
setupFiles: ['./jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|@react-navigation)/)',
],
};The transformIgnorePatterns is the line that breaks most teams' first Jest setup. Most RN-related packages ship ESM and must be allowed through the transformer.
Flutter
project/
├── lib/
│ └── ...
└── test/
├── unit/
│ └── validators_test.dart
└── widget/
└── order_page_test.dartflutter test discovers everything under test/ whose name ends with _test.dart.
What Belongs in a Unit Test
Pure functions
The easiest tests; write a lot of them.
import { formatCurrency } from './format';
test('formats positive amount with currency symbol', () => {
expect(formatCurrency(1234.5, 'NZD')).toBe('NZ$1,234.50');
});
test('rounds half-away-from-zero', () => {
expect(formatCurrency(0.005, 'NZD')).toBe('NZ$0.01');
});
test('returns empty string for NaN', () => {
expect(formatCurrency(NaN, 'NZD')).toBe('');
});A pure function should have one test per behavior class: happy path, each boundary, each error case. Not one test per input value.
Reducers and state machines
Deterministic input-output; trivial to test.
test('ADD_ITEM appends to the cart', () => {
const before = { items: [{ id: 1 }] };
const after = cartReducer(before, { type: 'ADD_ITEM', item: { id: 2 } });
expect(after.items).toEqual([{ id: 1 }, { id: 2 }]);
});
test('REMOVE_ITEM filters by id', () => {
const before = { items: [{ id: 1 }, { id: 2 }] };
const after = cartReducer(before, { type: 'REMOVE_ITEM', id: 1 });
expect(after.items).toEqual([{ id: 2 }]);
});Hooks with internal logic
@testing-library/react's renderHook exercises a hook in isolation.
import { renderHook, act } from '@testing-library/react';
import { useDebouncedValue } from './useDebouncedValue';
test('debounces value updates', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(
({ v }) => useDebouncedValue(v, 100),
{ initialProps: { v: 'a' } },
);
rerender({ v: 'b' });
expect(result.current).toBe('a'); // not yet
act(() => vi.advanceTimersByTime(100));
expect(result.current).toBe('b'); // now
vi.useRealTimers();
});Note the fake timers. setTimeout in real time would force a real wait and inject flakiness.
Selectors and derived state
test('selectTotal sums quantity * price', () => {
const state = { items: [{ price: 10, qty: 2 }, { price: 5, qty: 1 }] };
expect(selectTotal(state)).toBe(25);
});If the selector is memoized (Reselect, Riverpod Provider), assert the cache behavior in a separate test — easy to break inadvertently.
Validators
test.each([
['', 'required'],
['a', 'too short'],
['abc', null],
['a'.repeat(101), 'too long'],
])('validate(%s) → %s', (input, expected) => {
expect(validate(input)).toBe(expected);
});test.each is the right tool for tabular cases; avoids one near-duplicate test per row.
What Does Not Belong in a Unit Test
- Rendering a React component just to check an internal prop. That belongs in a component test.
- Asserting "useEffect ran." That tests the framework.
- A test whose entire setup mocks four modules. The unit being tested is the wrong size; either split the unit or move the test to component / integration.
- Pixel positions. Visual regression.
Mocking Without Overdoing It
The biggest unit-test failure mode is mocking so much that the test verifies the mocks, not the code. Defensive rules:
Mock at module boundaries
// Good: mock the network adapter at the boundary
vi.mock('./api/client', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}));
// Bad: mock something the unit owns
vi.mock('./internal/formatUserName', () => ({ ... }));If you find yourself mocking internals, the unit is too small or coupled to internals it should not be.
Prefer fakes over mocks when reasonable
// Mock: brittle, asserts how the function was called
const sendMock = vi.fn();
const service = new NotificationService(sendMock);
service.notify('hi');
expect(sendMock).toHaveBeenCalledWith({ body: 'hi', priority: 'normal' });
// Fake: tests behavior, survives refactors
class FakeTransport {
sent: Message[] = [];
send(msg: Message) { this.sent.push(msg); }
}
const transport = new FakeTransport();
const service = new NotificationService(transport);
service.notify('hi');
expect(transport.sent).toEqual([{ body: 'hi', priority: 'normal' }]);The fake version still passes after a refactor that splits send() into prepare() and dispatch(). The mock version does not.
Inject side-effect dependencies
// Hidden time dependence — test is flaky around midnight
function isOpen(hours: BusinessHours) {
return hours.start <= new Date().getHours() && new Date().getHours() < hours.end;
}
// Time as a parameter — tests pass any moment they want
function isOpen(hours: BusinessHours, now: Date = new Date()) {
return hours.start <= now.getHours() && now.getHours() < hours.end;
}Same for randomness, locale, browser features. If the function depends on it, inject it.
Flutter Unit Tests
flutter test runs Dart code without a UI. Use it for everything non-Widget.
// test/unit/cart_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/cart/cart.dart';
void main() {
group('Cart', () {
test('adds items', () {
final cart = Cart();
cart.add(Item(id: 1, price: 10));
expect(cart.items, hasLength(1));
});
test('computes total', () {
final cart = Cart()
..add(Item(id: 1, price: 10))
..add(Item(id: 2, price: 5));
expect(cart.total, 15);
});
});
}Matcher patterns:
expect(actual, equals(expected))for value equality.expect(future, throwsA(isA<TimeoutException>()))for async errors.expect(stream, emitsInOrder([1, 2, 3, emitsDone]))for streams.
Mocking in Dart
mocktail is the modern default; it does not require code generation.
import 'package:mocktail/mocktail.dart';
class _MockApi extends Mock implements Api {}
test('loads user', () async {
final api = _MockApi();
when(() => api.fetchUser(1))
.thenAnswer((_) async => User(id: 1, name: 'Alice'));
final service = UserService(api);
final user = await service.load(1);
expect(user.name, 'Alice');
verify(() => api.fetchUser(1)).called(1);
});Same rule applies: mock at the boundary, not inside the unit.
Coverage as a Lagging Indicator
Coverage numbers are useful for tracking the trend, not for setting absolute thresholds.
- High coverage with low signal. A test that asserts
expect(true).toBe(true)is 100% coverage and zero value. - Low coverage with high signal. A small number of focused tests around critical logic catches more bugs than a large number around getters.
A reasonable use of coverage: gate PRs on not decreasing coverage, not on hitting an arbitrary percentage. Look at uncovered lines in review and decide if they need a test.
Pre-Commit Checklist
Before submitting a PR with new unit tests:
- Each test has a single, named behavior.
- No real timers, real network, or real time-of-day inputs.
- Mocks are at module boundaries; no mocking of internals.
- Boundary cases tested: empty, single, max, null, error.
- If a behavior is described in the PR, a test fails when that behavior is broken.
- Test names read as specifications, not as implementation summaries.