Skip to main content

Writing Tests

One principle governs every test in this repo: test the behavior an operator would notice, never the implementation a refactor would change. A test that checks "the menu opens when clicked" survives a rewrite of the menu; a test that checks internal state dies with it. This lesson is the house style for getting there. (For the mechanics of faking dependencies, see the Mocking Playbook; for RTL's full API, use its own docs — we won't duplicate them.)

Warm up your reviewer's eye first — five assertions, and you call each one before the explanations below tell you why:

⚖️ Flaky or Solid?case 1/5 · score 0
await new Promise(r => setTimeout(r, 500));
expect(screen.getByText('Camera moved')).toBeVisible();

The shape of a test file

import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import MeasurementToggle from './MeasurementToggle';

// 1. Mocks first (CSS modules, heavy dependencies)
jest.mock('./MeasurementToggle.module.css', () => ({
container: 'container-class',
}));

describe('MeasurementToggle', () => {
// 2. Shared props and mocks
const defaultProps = { isActive: true, onToggleTool: jest.fn() };

// 3. Clean slate per test
beforeEach(() => jest.clearAllMocks());

// 4. Group by concern
describe('Rendering', () => {
/* ... */
});
describe('User Interactions', () => {
/* ... */
});
});

The grouping is the part that scales: six months later, a failing Rendering › does not render when inactive tells you what broke before you open the file.

Query like a user

Find elements the way an operator (or their screen reader) would — which means RTL's accessible queries, in priority order:

// ✅ Best: by role and accessible name
screen.getByRole('button', { name: /measure distance/i });

// ✅ Good: by label
screen.getByLabelText('Email address');

// ⚠️ Last resort, when nothing semantic exists
screen.getByTestId('custom-component');

// ❌ Never: implementation details
container.querySelector('.submit-button');

This rule pays twice: it makes the test refactor-proof, and it fails on genuinely inaccessible markup — the same alignment you saw in click tracking, where aria-label feeds analytics too. Accessible components are the cheap path everywhere in this app.

The variant matters as much as the query: getBy* when it must exist, queryBy* only for asserting absence, findBy* (with await) for elements that appear asynchronously.

Interact like a user

Prefer userEvent — it simulates the real event sequence (focus, keydown, input…), where fireEvent fires one synthetic event:

const user = userEvent.setup();
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.click(screen.getByRole('button', { name: /submit/i }));

And for anything async — data loading, menus animating closed — assert through waitFor or findBy* rather than sprinkling timeouts:

await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument());

Name tests as specifications

The suite doubles as living documentation of intended behavior — write names that would make sense in a spec review:

// ✅ Behavior, readable as a requirement
test('disables submit button while loading', () => {});
test('calls onToggleTool with "area" when the area button is clicked', () => {});

// ❌ Vague — tells a future reader nothing
test('works correctly', () => {});
test('handles click', () => {});

Wrapping up

Structure by concern, query by role, interact with userEvent, await with findBy/waitFor, and name every test as a requirement. When your component's dependencies are the problem — stores, ArcGIS, sessions — that's the next lesson: the Mocking Playbook.