
writing-tests
Principles for writing effective, maintainable tests. Covers naming conventions, assertion best prac
Writing Tests
Overview
This skill governs the inside of an individual test: what to name it, what to assert, and which edge cases to cover. Four rules make a test valuable: test names describe outcomes not actions, assertions match the test title, assertions check specific values not types, and each test verifies one concept.
Why this matters: A weak test passes whether or not the code is correct, giving false confidence that is worse than no test. Outcome-based names turn the suite into a readable specification: reading the names alone should tell you what the system does. Specific assertions catch specific bugs; toBeDefined() catches almost nothing. And bugs cluster: one discovered defect usually signals a misunderstanding that produced several, so each bug found is a prompt to test its neighbors.
Based on BugMagnet by Gojko Adzic. Adapted with attribution and aligned with
fn(args, deps)and Result type patterns.
When to Use
- Writing new tests, especially the failing test in a TDD RED phase
- Reviewing test quality or refactoring tests for maintainability
- Expanding coverage with edge cases
- Turning a discovered bug into a cluster of related test cases
When NOT to use: Choosing which layer of the pyramid a test belongs to (see testing-strategy); driving the overall red-green-refactor loop (see tdd-workflow).
Related: tdd-workflow calls this skill during its RED phase; testing-strategy decides the test layer and mocking approach; result-types and validation-boundary define the error and validation outcomes these tests assert on; fn-args-deps supplies the deps shape mocked in each test.
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.value).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.
Test Naming
Pattern: [outcome] when [condition]
Good Names (Describe Outcomes)
// ✅ GOOD: Describes what happens
it('returns empty array when input is null', () => {});
it('returns err NOT_FOUND when user does not exist', () => {});
it('calculates tax correctly for tax-exempt items', () => {});
it('preserves original order when duplicates removed', () => {});
it('returns ok with user when found in database', () => {});
Bad Names (Describe Actions)
// ❌ BAD: Describes what you're doing, not what happens
it('test null input', () => {}); // What about null input?
it('should work', () => {}); // What does "work" mean?
it('handles edge cases', () => {}); // Which edge cases?
it('email validation test', () => {}); // What's being validated?
it('test getUser', () => {}); // What does getUser do?
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.
describe('getUser', () => {
it('returns ok with user when found in database', () => {});
it('returns err NOT_FOUND when user does not exist', () => {});
it('returns err DB_ERROR when database connection fails', () => {});
it('returns err VALIDATION_ERROR when userId is empty string', () => {});
});
// Reading just these names tells you everything getUser does.
Assertion Best Practices
Assert Specific Values
// ❌ WEAK - passes even if completely wrong data
it('returns user when found', async () => {
const result = await getUser({ userId: '123' }, deps);
expect(result).toBeDefined();
expect(result.ok).toBeTruthy();
if (result.ok) {
expect(result.value).toBeTruthy();
}
});
// ✅ STRONG - catches actual bugs
it('returns ok with user when found in database', async () => {
const mockUser = { id: '123', name: 'Alice', email: 'alice@test.com' };
const deps = mock<GetUserDeps>();
deps.db.findUser.mockResolvedValue(mockUser);
const result = await getUser({ userId: '123' }, deps);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toEqual(mockUser);
expect(result.value.email).toBe('alice@test.com');
}
});
Match Assertions to Test Title
// ❌ TEST SAYS "different IDs" BUT ASSERTS COUNT
it('generates different IDs for each call', () => {
const id1 = generateId();
const id2 = generateId();
expect([id1, id2]).toHaveLength(2); // WRONG: doesn't check they're different!
});
// ✅ ACTUALLY VERIFIES DIFFERENT IDs
it('generates different IDs for each call', () => {
const id1 = generateId();
const id2 = generateId();
expect(id1).not.toBe(id2); // RIGHT: verifies the claim
expect(id1).toMatch(/^[a-z0-9-]+$/); // Also verify format
expect(id2).toMatch(/^[a-z0-9-]+$/);
});
Testing Result Types
When testing functions that return Result<T, E>, assert on the specific error type and value:
// ❌ WEAK - doesn't verify error type
it('handles missing user', async () => {
const result = await getUser({ userId: 'missing' }, deps);
expect(result.ok).toBe(false);
});
// ✅ STRONG - verifies exact error
it('returns err NOT_FOUND when user does not exist', async () => {
const deps = mock<GetUserDeps>();
deps.db.findUser.mockResolvedValue(null);
const result = await getUser({ userId: 'missing' }, deps);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe('NOT_FOUND');
}
});
Avoid Implementation Coupling
// ❌ BRITTLE - tests implementation details
it('queries database with correct SQL', async () => {
const deps = mock<GetUserDeps>();
await getUser({ userId: '123' }, deps);
expect(deps.db.findUser).toHaveBeenCalledWith('123');
// What if we change to findUser({ where: { id: '123' } })? Test breaks.
});
// ✅ FLEXIBLE - tests behavior
it('returns ok with user when found in database', async () => {
const mockUser = { id: '123', name: 'Alice' };
const deps = mock<GetUserDeps>();
deps.db.findUser.mockResolvedValue(mockUser);
const result = await getUser({ userId: '123' }, deps);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toEqual(mockUser);
}
// Implementation can change (SQL, ORM, etc.) and test still passes
});
Test Structure
Arrange-Act-Assert
it('calculates total with tax for non-exempt items', () => {
// Arrange: Set up test data and mocks
const item = { price: 100, taxExempt: false };
const taxRate = 0.1;
const deps = mock<CalculateTotalDeps>();
deps.config.getTaxRate.mockReturnValue(taxRate);
// Act: Execute the behavior
const result = calculateTotal({ item }, deps);
// Assert: Verify the outcome
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe(110);
}
});
One Concept Per Test
// ❌ MULTIPLE CONCEPTS - hard to diagnose failures
it('validates and processes order', () => {
expect(validate(order)).toBe(true);
expect(process(order).ok).toBe(true);
if (process(order).ok) {
expect(sendEmail).toHaveBeenCalled();
}
});
// ✅ SINGLE CONCEPT - clear failures
it('accepts valid orders', () => {
const result = validate(validOrder);
expect(result).toBe(true);
});
it('rejects orders with negative quantities', () => {
const result = validate(negativeQuantityOrder);
expect(result).toBe(false);
});
it('sends confirmation email after processing', async () => {
const deps = mock<ProcessOrderDeps>();
deps.mailer.send.mockResolvedValue(undefined);
const result = await processOrder(validOrder, deps);
expect(result.ok).toBe(true);
expect(deps.mailer.send).toHaveBeenCalledWith(validOrder.customerEmail);
});
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)
describe('calculateTotal', () => {
it('returns err INVALID when price is negative', () => {});
it('returns err INVALID when price is zero', () => {});
it('returns err INVALID when price is NaN', () => {});
it('handles very large prices near MAX_SAFE_INTEGER', () => {});
it('handles decimal precision correctly', () => {
// 0.1 + 0.2 = 0.30000000000000004
});
});
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
describe('validateEmail', () => {
it('returns err INVALID when email is empty string', () => {});
it('returns err INVALID when email is whitespace only', () => {});
it('handles unicode characters in email', () => {});
it('handles plus signs in email addresses', () => {
// user+tag@example.com
});
});
Collections (Arrays, Objects, Maps)
- Empty collection
[],{} - Single element
- Duplicates
- Nested structures
- Circular references
- Very large collections (performance)
- Sparse arrays
- Mixed types in arrays
describe('removeDuplicates', () => {
it('returns empty array when input is empty', () => {});
it('returns same array when input has one element', () => {});
it('removes duplicate values', () => {});
it('preserves order of first occurrence', () => {});
it('handles nested arrays correctly', () => {});
});
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
describe('calculateAge', () => {
it('handles leap year birthdays correctly', () => {});
it('handles birthday on Feb 29 in non-leap years', () => {});
it('handles timezone boundaries correctly', () => {});
it('returns err INVALID when date is in the future', () => {});
});
Null and Undefined
-
nullinput -
undefinedinput - Missing optional properties
- Explicit
undefinedvs missing key
describe('getUser', () => {
it('returns err NOT_FOUND when user is null', () => {});
it('returns err VALIDATION_ERROR when userId is undefined', () => {});
it('handles missing optional properties gracefully', () => {});
});
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%
describe('validateEmail', () => {
it('accepts emails with plus signs', () => {});
it('accepts emails with subdomains', () => {});
it('rejects emails without @ symbol', () => {});
it('rejects emails with invalid TLD', () => {});
});
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)
describe('createOrder', () => {
it('returns err DUPLICATE when order ID already exists', () => {});
it('returns err INVALID when customer does not exist', () => {});
it('returns err INVALID when quantity exceeds maximum', () => {});
it('returns err INVALID when order date is after ship date', () => {});
});
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
// Found bug: getUser returns wrong error for null
// Don't just fix null, test related scenarios:
it('returns err NOT_FOUND when user is null', () => {});
it('returns err NOT_FOUND when user is undefined', () => {});
it('returns err VALIDATION_ERROR when userId is empty string', () => {});
it('returns err VALIDATION_ERROR when userId is whitespace', () => {});
it('returns err VALIDATION_ERROR when userId is null', () => {});
Common Rationalizations
| Rationalization | Reality |
|---|---|
"should work is a fine test name" | A name that doesn't state the outcome documents nothing. Name the outcome: "returns empty array when input is null". |
"toBeDefined() proves it works" | It passes on completely wrong data. Assert the specific expected value. |
| "Asserting count is close enough for 'different IDs'" | The assertion must match the title's claim. Assert that the IDs differ. |
| "One big test covers more" | Multiple concepts in one test make failures ambiguous. Split until each test has one reason to fail. |
| "I fixed the bug, one test is enough" | Bugs cluster. The same misunderstanding usually produced neighbors, so test them. |
| "That edge case won't happen" | It will happen in production, at 3 AM. Use the edge-case checklists. |
Red Flags
- Test names containing "test", "should work", "handles errors", or "edge cases"
- Assertions using
toBeDefined(),toBeTruthy(), ortoHaveLength()where a value is known - An assertion that contradicts or under-checks the test title
- A test name needing "and" to describe what it verifies
- A bug fix accompanied by exactly one new test
- Assertions on internal calls (
toHaveBeenCalledWith) instead of observable outcomes - Edge cases skipped with "that can't happen"
Integration with Other Skills
With TDD Workflow: This skill guides the RED phase: how to write the failing test well. Use outcome-based naming and specific assertions from the start.
With Testing Strategy: This skill complements the test pyramid. Unit tests (with mocks) and integration tests (with real DB) both benefit from good naming and edge case coverage.
With fn(args, deps): Tests use mock<DepsType>() from vitest-mock-extended. No vi.mock() for application logic. See testing-strategy for details.
With Result Types: Tests assert on result.ok and specific error types. See tdd-workflow for Result type testing patterns.
With Design Principles: Testable code follows design principles. Hard-to-test code often has design problems. If you can't write a good test, the function might be doing too much.
Examples with fn(args, deps) Pattern
Complete Example: Testing a User Function
import { describe, it, expect } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { getUser, type GetUserDeps } from './get-user';
describe('getUser', () => {
it('returns ok with user when found in database', async () => {
// Arrange
const mockUser = { id: '123', name: 'Alice', email: 'alice@test.com' };
const deps = mock<GetUserDeps>();
deps.db.findUser.mockResolvedValue(mockUser);
// Act
const result = await getUser({ userId: '123' }, deps);
// Assert
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toEqual(mockUser);
expect(result.value.email).toBe('alice@test.com');
}
});
it('returns err NOT_FOUND when user does not exist', async () => {
// Arrange
const deps = mock<GetUserDeps>();
deps.db.findUser.mockResolvedValue(null);
// Act
const result = await getUser({ userId: 'missing' }, deps);
// Assert
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe('NOT_FOUND');
}
});
it('returns err VALIDATION_ERROR when userId is empty string', async () => {
// Arrange
const deps = mock<GetUserDeps>();
// Act
const result = await getUser({ userId: '' }, deps);
// Assert
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe('VALIDATION_ERROR');
}
});
it('returns err DB_ERROR when database connection fails', async () => {
// Arrange
const deps = mock<GetUserDeps>();
deps.db.findUser.mockRejectedValue(new Error('Connection failed'));
// Act
const result = await getUser({ userId: '123' }, deps);
// Assert
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBe('DB_ERROR');
}
});
});
The Rules
- Test names describe outcomes - "returns X when Y" not "test Y"
- Assertions match test titles - If title says "different IDs", assert they're different
- Assert specific values -
toEqual(expected)nottoBeDefined() - One concept per test - Split tests that need "and" in the name
- Bugs cluster together - When you find one, test related scenarios
- Use edge case checklists - Systematically cover numbers, strings, dates, null, domain constraints
- Test behavior, not implementation - Assert on Result types, not internal calls
- Use Arrange-Act-Assert - Clear structure makes tests readable
- No vi.mock() for app logic - Use
mock<DepsType>()from vitest-mock-extended - Test Result types explicitly - Assert on
result.okand specific error types
Verification
- Every test name states an outcome in "[outcome] when [condition]" form
- Each assertion verifies the exact claim in the test title
- Assertions check specific values, not
toBeDefined/toBeTruthy - Each test verifies exactly one concept (no "and" in the name)
- Result-returning functions assert
result.okand the specific error - Relevant edge cases from the checklists are covered
- Discovered bugs spawned tests for clustered scenarios
- Tests assert observable behavior, not internal calls