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