Writing Tests
How to write tests that catch bugs, document behavior, and remain maintainable.
Table of Contents
- Critical Rules
- When This Applies
- Test Naming
- Assertion Best Practices
- Test Structure
- Edge Case Checklists
- Bug Clustering
- When Tempted to Cut Corners
- Integration with Other Skills
Critical Rules
🚨 Test names describe outcomes, not actions. "returns empty array when input is null" not "test null input". The name IS the specification.
🚨 Assertions must match test titles. If the test claims to verify "different IDs", assert on the actual ID values—not just count or existence.
🚨 Assert specific values, not types. expect(result).toEqual(['First.', ' Second.']) not expect(result).toBeDefined(). Specific assertions catch specific bugs.
🚨 One concept per test. Each test verifies one behavior. If you need "and" in your test name, split it.
🚨 Bugs cluster together. When you find one bug, test related scenarios. The same misunderstanding often causes multiple failures.
When This Applies
- Writing new tests
- Reviewing test quality
- During TDD RED phase (writing the failing test)
- Expanding test coverage
- Investigating discovered bugs
Test Naming
Pattern: [outcome] when [condition]
Good Names (Describe Outcomes)
returns empty array when input is null
throws ValidationError when email format invalid
calculates tax correctly for tax-exempt items
preserves original order when duplicates removed
Bad Names (Describe Actions)
test null input // What about null input?
should work // What does "work" mean?
handles edge cases // Which edge cases?
email validation test // What's being validated?
The Specification Test
Your test name should read like a specification. If someone reads ONLY the test names, they should understand the complete behavior of the system.
Assertion Best Practices
See resources/assertion-examples.ts for complete examples covering:
- Assert Specific Values — weak (
toBeDefined) vs strong (toEqual) assertions - Match Assertions to Test Title — asserting count vs asserting actual difference
- Avoid Implementation Coupling — testing implementation details vs behavior
Test Structure
See resources/test-structure-examples.ts for complete examples covering:
- Arrange-Act-Assert — the standard test structure pattern
- One Concept Per Test — multiple concepts vs single concept per test
Edge Case Checklists
When testing a function, systematically consider these edge cases based on input types.
Numbers
- Zero
- Negative numbers
- Very large numbers (near MAX_SAFE_INTEGER)
- Very small numbers (near MIN_SAFE_INTEGER)
- Decimal precision (0.1 + 0.2)
- NaN
- Infinity / -Infinity
- Boundary values (off-by-one at limits)
Strings
- Empty string
"" - Whitespace only
" " - Very long strings (10K+ characters)
- Unicode: emojis 👨👩👧👦, RTL text, combining characters
- Special characters: quotes, backslashes, null bytes
- SQL/HTML/script injection patterns
- Leading/trailing whitespace
- Mixed case sensitivity
Collections (Arrays, Objects, Maps)
- Empty collection
[],{} - Single element
- Duplicates
- Nested structures
- Circular references
- Very large collections (performance)
- Sparse arrays
- Mixed types in arrays
Dates and Times
- Leap years (Feb 29)
- Daylight saving transitions
- Timezone boundaries
- Midnight (00:00:00)
- End of day (23:59:59)
- Year boundaries (Dec 31 → Jan 1)
- Invalid dates (Feb 30, Month 13)
- Unix epoch edge cases
- Far future/past dates
Null and Undefined
-
nullinput -
undefinedinput - Missing optional properties
- Explicit
undefinedvs missing key
Domain-Specific
- Email: valid formats, edge cases (plus signs, subdomains)
- URLs: protocols, ports, special characters, relative paths
- Phone numbers: international formats, extensions
- Addresses: Unicode, multi-line, missing components
- Currency: rounding, different currencies, zero amounts
- Percentages: 0%, 100%, over 100%
Violated Domain Constraints
These test implicit assumptions in your domain:
- Uniqueness violations (duplicate IDs, emails)
- Missing required relationships (orphaned records)
- Ordering violations (events out of sequence)
- Range breaches (age -1, quantity 1000000)
- State inconsistencies (shipped but not paid)
- Format mismatches (expected JSON, got XML)
- Temporal ordering (end before start)
Typed Property Validation
When testing code that validates properties against type constraints (e.g., validating route: string in an interface):
Wrong-type literals:
- Numeric literal when string expected (
route = 123) - Boolean literal when string expected (
route = true) - String literal when number expected (
count = 'five') - String literal when boolean expected (
enabled = 'yes')
Non-literal expressions:
- Template literal (
route = `/path/${id}`) - Variable reference (
route = someVariable) - Function call (
route = getRoute()) - Computed property (
route = config.path)
Correct type:
- Valid literal of correct type (
route = '/orders') - Edge values (empty string
'', zero0,false)
Why this matters: A common bug pattern is validating "is this a literal?" without checking "is this the RIGHT TYPE of literal?"
hasLiteralValue()returns true for123,true, and'string'hasStringLiteralValue()returns true only for'string'
When an interface specifies property: string, validation must reject numeric and boolean literals, not just non-literal expressions.
Bug Clustering
When you discover a bug, don't stop—explore related scenarios:
- Same function, similar inputs - If null fails, test undefined, empty string
- Same pattern, different locations - If one endpoint mishandles auth, check others
- Same developer assumption - If off-by-one here, check other boundaries
- Same data type - If dates fail at DST, check other time edge cases
When Tempted to Cut Corners
If your test name says "test" or "should work": STOP. What outcome are you actually verifying? Name it specifically.
If you're asserting
toBeDefined()ortoBeTruthy(): STOP. What value do you actually expect? Assert that instead.If your assertion doesn't match your test title: STOP. Either fix the assertion or rename the test. They must agree.
If you're testing multiple concepts in one test: STOP. Split it. Future you debugging a failure will thank you.
If you found a bug and wrote one test: STOP. Bugs cluster. What related scenarios might have the same problem?
If you're skipping edge cases because "that won't happen": STOP. It will happen. In production. At 3 AM.
DSAI Component Testing
When testing DSAI components, follow these additional patterns:
Component Test Structure
import { render, screen, fireEvent } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '@/components/ui/button';
expect.extend(toHaveNoViolations);
describe('Button', () => {
it('renders with correct variant class', () => {
render(<Button variant="primary">Click me</Button>);
expect(screen.getByRole('button')).toHaveClass('btn', 'btn-primary');
});
it('shows spinner when loading', () => {
render(<Button loading>Save</Button>);
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeDisabled();
});
it('forwards ref to button element', () => {
const ref = { current: null };
render(<Button ref={ref}>Click</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
});
Accessibility Tests (Separate File)
// Button.a11y.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Button } from '@/components/ui/button';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<Button variant="primary">Click me</Button>);
expect(await axe(container)).toHaveNoViolations();
});
DSAI-Specific Test Checklist
-
forwardRef— verify ref is forwarded to DOM element -
displayName— verify component.displayName matches expected name -
cn()— verify className composition with custom classes - FSM states — test all state transitions (idle → hovered → pressed → loading → error)
- Compound components — test sub-components throw when used outside parent context
- Controlled/Uncontrolled — test both
value+onChangeanddefaultValuemodes - Accessibility — separate
.a11y.test.tsxfile with jest-axe - SemanticColorVariant — test all 8 color variants render correct classes
- ComponentSize — test sm/md/lg size variants
forwardRef Forwarding Test
it('forwards ref to the root DOM element', () => {
const ref = React.createRef<HTMLButtonElement>();
render(<Button ref={ref}>Click</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
expect(ref.current?.tagName).toBe('BUTTON');
});
displayName Verification Test
it('has correct displayName', () => {
expect(Button.displayName).toBe('Button');
});
FSM State Transition Test
import { buttonFSMReducer, createInitialButtonFSMState } from './Button.fsm';
describe('Button FSM', () => {
it('transitions idle → hovered on HOVER', () => {
const initial = createInitialButtonFSMState({});
const next = buttonFSMReducer(initial, { type: 'HOVER' });
expect(next.status).toBe('hovered');
});
it('transitions hovered → pressed on PRESS', () => {
const hovered = buttonFSMReducer(
createInitialButtonFSMState({}),
{ type: 'HOVER' }
);
const pressed = buttonFSMReducer(hovered, { type: 'PRESS' });
expect(pressed.status).toBe('pressed');
});
it('stays disabled when receiving HOVER in disabled state', () => {
const disabled = buttonFSMReducer(
createInitialButtonFSMState({}),
{ type: 'DISABLE' }
);
const result = buttonFSMReducer(disabled, { type: 'HOVER' });
expect(result.status).toBe('disabled');
});
});
cn() Class Composition Test
import { cn } from '@/utils/cn';
describe('cn()', () => {
it('joins truthy values', () => {
expect(cn('btn', 'btn-primary', 'btn-lg')).toBe('btn btn-primary btn-lg');
});
it('filters out falsy values', () => {
expect(cn('btn', false && 'btn-primary', undefined, null, '')).toBe('btn');
});
it('applies conditional classes', () => {
const variant = 'danger';
const size: string | undefined = undefined;
expect(cn('btn', `btn-${variant}`, size && `btn-${size}`)).toBe('btn btn-danger');
});
});
Compound Component Context Test
it('throws when sub-component used outside parent', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Card.Header>Orphan</Card.Header>)).toThrow();
consoleSpy.mockRestore();
});
Integration with Other Skills
With TDD Process: This skill guides the RED phase—how to write the failing test well.
With Software Design Principles: Testable code follows design principles. Hard-to-test code often has design problems.