Steven's Knowledge

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:

RunnerDefault forNotes
VitestModern Vite-based projects (React, Vue, Svelte)ESM-native, fast, Jest-compatible API
JestOlder CRA / Webpack projects, React NativeMature, ubiquitous, slower startup
flutter testFlutterBuilt-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.dart

flutter 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.

On this page