
Tdd
- 352 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
tdd is an Agent Skills workflow that guides developers through vertical-slice red-green-refactor cycles so each feature gains behavior-focused integration tests before and during implementation.
About
tdd is an Agent Skills workflow from pproenca/dot-skills for developers who want test-first feature work using the red-green-refactor loop instead of bolting on tests after code ships. The skill enforces vertical tracer bullets—one failing test, minimal passing code, repeat—and rejects horizontal slicing where all tests are written before any implementation. It bundles 5 reference guides on integration tests, system-boundary mocking, interface design, deep modules, and refactor candidates, with TypeScript examples that verify behavior through public APIs only. A 4-phase workflow covers planning with user approval, a tracer-bullet proof, incremental red-green cycles, and post-green refactoring guarded by a 5-item checklist per cycle. Developers reach for tdd when building features or fixing bugs where regressions matter, when they mention red-green-refactor or test-first development, or when agents need guardrails against implementation-coupled mocks.
- tdd
Tdd by the numbers
- 352 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,155 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill tddAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 352 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you do test-driven development with red-green-refactor?
Use tdd for development tasks
Who is it for?
Developers building features or fixing bugs who want test-first vertical slices through public interfaces instead of bulk test scaffolding.
Skip if: Developers who need load testing, browser E2E automation, or throwaway scripts without an existing project test runner.
When should I use this skill?
User requests TDD, red-green-refactor, test-first development, integration tests, or feature work where tests must verify public behavior.
What you get
Passing integration tests, refactored production code, and a behavior-focused test suite per feature slice
- failing then passing integration tests
- refactored implementation behind public APIs
By the numbers
- bundles 5 reference markdown guides (tests, mocking, interface-design, deep-modules, refactoring)
- defines 4 workflow phases from planning through refactor
- uses 5-item checklist per red-green-refactor cycle
Files
Community Test-Driven Development Best Practices
Comprehensive guide to Test-Driven Development practices, designed for AI agents and LLMs. Contains 42 rules across 8 categories, prioritized by impact to guide test writing, refactoring, and code generation.
When to Apply
Reference these guidelines when:
- Writing new tests using TDD workflow
- Implementing the red-green-refactor cycle
- Designing test structure and organization
- Creating test data and fixtures
- Reviewing or refactoring existing test suites
TDD Workflow
1. RED: Write a failing test that defines desired behavior 2. GREEN: Write minimal code to make the test pass 3. REFACTOR: Clean up code while keeping tests green 4. Repeat for each new behavior
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Red-Green-Refactor Cycle | CRITICAL | cycle- |
| 2 | Test Design Principles | CRITICAL | design- |
| 3 | Test Isolation & Dependencies | HIGH | isolate- |
| 4 | Test Data Management | HIGH | data- |
| 5 | Assertions & Verification | MEDIUM | assert- |
| 6 | Test Organization & Structure | MEDIUM | org- |
| 7 | Test Performance & Reliability | MEDIUM | perf- |
| 8 | Test Pyramid & Strategy | LOW | strat- |
Quick Reference
1. Red-Green-Refactor Cycle (CRITICAL)
cycle-write-test-first- Write the Test Before the Implementationcycle-minimal-code-to-pass- Write Only Enough Code to Pass the Testcycle-refactor-after-green- Refactor Immediately After Greencycle-verify-test-fails-first- Verify the Test Fails Before Writing Codecycle-small-increments- Take Small Incremental Stepscycle-maintain-test-list- Maintain a Test List
2. Test Design Principles (CRITICAL)
design-test-behavior-not-implementation- Test Behavior Not Implementationdesign-one-assertion-per-test- One Logical Assertion Per Testdesign-descriptive-test-names- Use Descriptive Test Namesdesign-aaa-pattern- Follow the Arrange-Act-Assert Patterndesign-test-edge-cases- Test Edge Cases and Boundariesdesign-avoid-logic-in-tests- Avoid Logic in Tests
3. Test Isolation & Dependencies (HIGH)
isolate-mock-external-dependencies- Mock External Dependenciesisolate-no-shared-state- Avoid Shared Mutable State Between Testsisolate-deterministic-tests- Write Deterministic Testsisolate-prefer-stubs-over-mocks- Prefer Stubs Over Mocks for Queriesisolate-use-dependency-injection- Use Dependency Injection for Testability
4. Test Data Management (HIGH)
data-use-factories- Use Factories for Test Data Creationdata-minimal-setup- Keep Test Setup Minimaldata-avoid-mystery-guests- Avoid Mystery Guestsdata-unique-identifiers- Use Unique Identifiers Per Testdata-builder-pattern- Use Builder Pattern for Complex Objects
5. Assertions & Verification (MEDIUM)
assert-specific-assertions- Use Specific Assertionsassert-error-messages- Assert on Error Messages and Typesassert-no-assertions-antipattern- Every Test Must Have Assertionsassert-custom-matchers- Create Custom Matchers for Domain Assertionsassert-snapshot-testing- Use Snapshot Testing Judiciously
6. Test Organization & Structure (MEDIUM)
org-group-by-behavior- Group Tests by Behavior Not Methodorg-file-structure- Follow Consistent Test File Structureorg-setup-teardown- Use Setup and Teardown Hooks Appropriatelyorg-test-utilities- Extract Reusable Test Utilitiesorg-parameterized-tests- Use Parameterized Tests for Variations
7. Test Performance & Reliability (MEDIUM)
perf-fast-unit-tests- Keep Unit Tests Under 100msperf-avoid-network-calls- Eliminate Network Calls in Unit Testsperf-fix-flaky-tests- Fix Flaky Tests Immediatelyperf-parallelize-tests- Parallelize Independent Testsperf-avoid-sleep- Avoid Arbitrary Sleep Calls
8. Test Pyramid & Strategy (LOW)
strat-test-pyramid- Follow the Test Pyramidstrat-mutation-testing- Use Mutation Testing to Validate Test Qualitystrat-coverage-targets- Set Meaningful Coverage Targetsstrat-integration-boundaries- Test Integration at Service Boundariesstrat-e2e-critical-paths- Limit E2E Tests to Critical User Paths
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
- cycle-write-test-first - Write the Test Before the Implementation
- design-aaa-pattern - Follow the Arrange-Act-Assert Pattern
Related Skills
- For Vitest framework specifics, see
vitestskill - For API mocking with MSW, see
mswskill
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
Test-Driven Development
Version 0.1.0 Community January 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive guide to Test-Driven Development practices, designed for AI agents and LLMs. Contains 42 rules across 8 categories, prioritized by impact from critical (red-green-refactor cycle, test design principles) to strategic (test pyramid, coverage targets). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide test writing, refactoring, and code generation.
---
Table of Contents
1. Red-Green-Refactor Cycle — CRITICAL
- 1.1 Maintain a Test List — CRITICAL (prevents scope creep and forgotten cases)
- 1.2 Refactor Immediately After Green — CRITICAL (prevents technical debt accumulation)
- 1.3 Take Small Incremental Steps — CRITICAL (2-5× faster debugging from smaller change sets)
- 1.4 Verify the Test Fails Before Writing Code — CRITICAL (prevents false positives from untested code)
- 1.5 Write Only Enough Code to Pass the Test — CRITICAL (prevents over-engineering and YAGNI violations)
- 1.6 Write the Test Before the Implementation — CRITICAL (prevents 40-90% of defects)
2. Test Design Principles — CRITICAL
- 2.1 Avoid Logic in Tests — CRITICAL (eliminates bugs in test code itself)
- 2.2 Follow the Arrange-Act-Assert Pattern — CRITICAL (makes tests 2-3× more readable)
- 2.3 One Logical Assertion Per Test — CRITICAL (reduces failure diagnosis time to O(1))
- 2.4 Test Behavior Not Implementation — CRITICAL (reduces test brittleness by 50-80%)
- 2.5 Test Edge Cases and Boundaries — CRITICAL (catches 60-80% of production bugs)
- 2.6 Use Descriptive Test Names — CRITICAL (2-3× faster failure diagnosis)
3. Test Isolation & Dependencies — HIGH
- 3.1 Avoid Shared Mutable State Between Tests — HIGH (eliminates 74% of test order dependency bugs)
- 3.2 Mock External Dependencies — HIGH (makes tests 10-100× faster)
- 3.3 Prefer Stubs Over Mocks for Queries — HIGH (reduces test brittleness)
- 3.4 Use Dependency Injection for Testability — HIGH (enables isolation without hacks)
- 3.5 Write Deterministic Tests — HIGH (eliminates flaky test failures)
4. Test Data Management — HIGH
- 4.1 Avoid Mystery Guests — HIGH (2-3× faster test comprehension)
- 4.2 Keep Test Setup Minimal — HIGH (2-5× faster test execution and comprehension)
- 4.3 Use Builder Pattern for Complex Objects — HIGH (reduces complex setup code by 40-60%)
- 4.4 Use Factories for Test Data Creation — HIGH (reduces test setup code by 60-80%)
- 4.5 Use Unique Identifiers Per Test — HIGH (prevents test pollution)
5. Assertions & Verification — MEDIUM
- 5.1 Assert on Error Messages and Types — MEDIUM (prevents false positives from wrong errors)
- 5.2 Create Custom Matchers for Domain Assertions — MEDIUM (reduces assertion code by 60-80%)
- 5.3 Every Test Must Have Assertions — MEDIUM (prevents false passing tests)
- 5.4 Use Snapshot Testing Judiciously — MEDIUM (prevents snapshot blindness)
- 5.5 Use Specific Assertions — MEDIUM (2-5× faster debugging from better failure messages)
6. Test Organization & Structure — MEDIUM
- 6.1 Extract Reusable Test Utilities — MEDIUM (reduces duplication by 40-60%)
- 6.2 Follow Consistent Test File Structure — MEDIUM (reduces time finding tests)
- 6.3 Group Tests by Behavior Not Method — MEDIUM (2-3× faster test navigation and discovery)
- 6.4 Use Parameterized Tests for Variations — MEDIUM (reduces test code by 50-70%)
- 6.5 Use Setup and Teardown Hooks Appropriately — MEDIUM (reduces setup duplication by 30-50%)
7. Test Performance & Reliability — MEDIUM
- 7.1 Avoid Arbitrary Sleep Calls — MEDIUM (eliminates 54% of async-related flakiness)
- 7.2 Eliminate Network Calls in Unit Tests — MEDIUM (makes tests 10-100× faster)
- 7.3 Fix Flaky Tests Immediately — MEDIUM (preserves trust in test suite)
- 7.4 Keep Unit Tests Under 100ms — MEDIUM (enables rapid feedback loops)
- 7.5 Parallelize Independent Tests — MEDIUM (reduces suite time by 50-80%)
8. Test Pyramid & Strategy — LOW
- 8.1 Follow the Test Pyramid — LOW (reduces test infrastructure cost by 10-100×)
- 8.2 Limit E2E Tests to Critical User Paths — LOW (reduces maintenance burden)
- 8.3 Set Meaningful Coverage Targets — LOW (2-3× better ROI on testing effort)
- 8.4 Test Integration at Service Boundaries — LOW (prevents integration failures in production)
- 8.5 Use Mutation Testing to Validate Test Quality — LOW (detects 30-50% more weak assertions)
---
References
1. https://martinfowler.com/bliki/TestDrivenDevelopment.html 2. http://blog.cleancoder.com/uncle-bob/2014/12/17/TheCyclesOfTDD.html 3. https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices 4. https://testing.googleblog.com/2020/08/code-coverage-best-practices.html 5. https://semaphore.io/blog/aaa-pattern-test-automation 6. https://docs.pytest.org/en/stable/how-to/fixtures.html 7. https://martinfowler.com/articles/practical-test-pyramid.html
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
Rule Title Here
Brief explanation of the rule and why it matters (1-3 sentences). Focus on performance or quality implications.
Incorrect (description of what's wrong):
// Bad code example here
// Comment explaining the problem or cost
const badExample = inefficientApproach()Correct (description of what's right):
// Good code example here
// Comment explaining the benefit
const goodExample = efficientApproach()When NOT to use this pattern:
- Exception case 1
- Exception case 2
Reference: Link to documentation or resource
{
"version": "1.0.6",
"organization": "Community",
"technology": "Test-Driven Development",
"date": "January 2026",
"abstract": "Comprehensive guide to Test-Driven Development practices, designed for AI agents and LLMs. Contains 42 rules across 8 categories, prioritized by impact from critical (red-green-refactor cycle, test design principles) to strategic (test pyramid, coverage targets). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide test writing, refactoring, and code generation.",
"references": [
"https://martinfowler.com/bliki/TestDrivenDevelopment.html",
"http://blog.cleancoder.com/uncle-bob/2014/12/17/TheCyclesOfTDD.html",
"https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-best-practices",
"https://testing.googleblog.com/2020/08/code-coverage-best-practices.html",
"https://semaphore.io/blog/aaa-pattern-test-automation",
"https://docs.pytest.org/en/stable/how-to/fixtures.html",
"https://martinfowler.com/articles/practical-test-pyramid.html"
],
"category": "Testing"
}
Test-Driven Development Best Practices
A comprehensive collection of TDD best practices for AI agents and LLMs, containing 42 rules across 8 categories.
Overview
This skill provides guidelines for writing effective tests using Test-Driven Development methodology. Rules are organized by impact level, from critical (red-green-refactor cycle) to strategic (test pyramid).
Structure
test-driven-development/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide
├── metadata.json # Version, organization, references
├── README.md # This file
└── rules/
├── _sections.md # Category definitions
├── cycle-*.md # Red-green-refactor cycle rules
├── design-*.md # Test design principle rules
├── isolate-*.md # Test isolation rules
├── data-*.md # Test data management rules
├── assert-*.md # Assertion rules
├── org-*.md # Test organization rules
├── perf-*.md # Test performance rules
└── strat-*.md # Test strategy rulesGetting Started
1. Install dependencies:
pnpm install2. Build the compiled guide:
pnpm build3. Validate the skill:
pnpm validateCreating a New Rule
1. Determine the appropriate category from rules/_sections.md 2. Create a new file with the category prefix: {prefix}-{description}.md 3. Use the frontmatter template:
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM|LOW
impactDescription: Quantified impact (e.g., "2-10× improvement")
tags: prefix, keyword1, keyword2
---4. Run validation to check formatting
Category Prefixes
| Prefix | Category | Impact |
|---|---|---|
cycle- | Red-Green-Refactor Cycle | CRITICAL |
design- | Test Design Principles | CRITICAL |
isolate- | Test Isolation & Dependencies | HIGH |
data- | Test Data Management | HIGH |
assert- | Assertions & Verification | MEDIUM |
org- | Test Organization & Structure | MEDIUM |
perf- | Test Performance & Reliability | MEDIUM |
strat- | Test Pyramid & Strategy | LOW |
Rule File Structure
Each rule file should follow this template:
---
title: Rule Title
impact: MEDIUM
impactDescription: Quantified impact description
tags: prefix, tag1, tag2
---
## Rule Title
Brief explanation of why this rule matters (1-3 sentences).
**Incorrect (what's wrong):**
\`\`\`typescript
// Bad code example with comments explaining the issue
\`\`\`
**Correct (what's right):**
\`\`\`typescript
// Good code example with comments explaining the benefit
\`\`\`
Reference: [Link to authoritative source](https://example.com)File Naming Convention
Rule files follow the pattern: {prefix}-{description}.md
prefix: Category identifier (3-8 characters)description: Lowercase, hyphen-separated description
Examples:
cycle-write-test-first.mddesign-aaa-pattern.mdisolate-mock-external-dependencies.md
Impact Levels
| Level | Description | Examples |
|---|---|---|
| CRITICAL | Foundational practices that prevent major issues | Red-green cycle, test-first approach |
| HIGH | Important practices with significant benefits | Test isolation, proper mocking |
| MEDIUM | Good practices that improve quality | Clear assertions, organization |
| LOW | Strategic considerations | Test pyramid, coverage targets |
Scripts
pnpm build- Compiles all rules into AGENTS.mdpnpm validate- Validates skill structure and contentpnpm lint- Checks markdown formatting
Contributing
1. Read existing rules to understand the style 2. Research authoritative sources for new rules 3. Follow the rule template exactly 4. Run validation before submitting 5. Include references to credible sources
Acknowledgments
This skill synthesizes best practices from:
- Kent Beck - Test Driven Development: By Example
- Martin Fowler - TDD articles and Practical Test Pyramid
- Robert C. Martin - Clean Code and TDD cycles
- Microsoft Learn - Unit testing best practices
- Google Testing Blog - Coverage best practices
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Red-Green-Refactor Cycle (cycle)
Impact: CRITICAL Description: The core TDD loop is the foundation of test-driven development. Breaking the cycle leads to untested code, design debt, and lost confidence in the test suite.
2. Test Design Principles (design)
Impact: CRITICAL Description: Well-designed tests are maintainable, readable, and catch real bugs. Poor test design creates brittle, hard-to-maintain tests that provide false confidence.
3. Test Isolation & Dependencies (isolate)
Impact: HIGH Description: Isolated tests run fast, are deterministic, and pinpoint failures precisely. Coupled tests create flaky suites that erode developer trust.
4. Test Data Management (data)
Impact: HIGH Description: Proper test data setup prevents mystery guests, reduces coupling between tests, and keeps tests focused on the behavior being verified.
5. Assertions & Verification (assert)
Impact: MEDIUM Description: Clear, specific assertions catch bugs and document expected behavior. Weak or missing assertions let bugs slip through undetected.
6. Test Organization & Structure (org)
Impact: MEDIUM Description: Well-organized test suites are maintainable and navigable. Poor organization hides tests, causes duplication, and increases maintenance burden.
7. Test Performance & Reliability (perf)
Impact: MEDIUM Description: Fast, reliable tests encourage frequent execution. Slow or flaky tests get ignored, reducing the value of the entire test suite.
8. Test Pyramid & Strategy (strat)
Impact: LOW Description: Strategic test distribution across unit, integration, and E2E layers optimizes coverage while minimizing maintenance cost and execution time.
Create Custom Matchers for Domain Assertions
For frequently-tested domain concepts, create custom matchers that express intent clearly and provide helpful failure messages.
Incorrect (repeated complex assertions):
test('creates valid order', () => {
const order = createOrder(orderData)
// Repeated validation logic in every test
expect(order.id).toMatch(/^ORD-\d{8}$/)
expect(order.status).toBe('pending')
expect(order.items.length).toBeGreaterThan(0)
expect(order.total).toBeGreaterThan(0)
expect(order.createdAt).toBeInstanceOf(Date)
})
test('checkout produces valid order', () => {
const order = await checkout(cart)
// Same checks duplicated
expect(order.id).toMatch(/^ORD-\d{8}$/)
expect(order.status).toBe('pending')
expect(order.items.length).toBeGreaterThan(0)
expect(order.total).toBeGreaterThan(0)
expect(order.createdAt).toBeInstanceOf(Date)
})Correct (custom domain matcher):
// test-utils/matchers.ts
expect.extend({
toBeValidOrder(received: unknown) {
const order = received as Order
const errors: string[] = []
if (!order.id?.match(/^ORD-\d{8}$/)) {
errors.push(`Invalid order ID: ${order.id}`)
}
if (order.status !== 'pending') {
errors.push(`Expected status 'pending', got '${order.status}'`)
}
if (!order.items?.length) {
errors.push('Order has no items')
}
if (!order.total || order.total <= 0) {
errors.push(`Invalid total: ${order.total}`)
}
return {
pass: errors.length === 0,
message: () => errors.join('\n')
}
}
})
// Clean, expressive tests
test('creates valid order', () => {
const order = createOrder(orderData)
expect(order).toBeValidOrder()
})
test('checkout produces valid order', () => {
const order = await checkout(cart)
expect(order).toBeValidOrder()
})Good candidates for custom matchers:
- Domain object validation
- Date/time comparisons
- Complex object structure checks
- API response validation
- State machine transitions
Reference: Jest Custom Matchers
Assert on Error Messages and Types
When testing error conditions, verify both the error type and message. Catching any error isn't enough - the right error must be thrown.
Incorrect (any error passes):
test('throws on invalid email', () => {
// Passes if ANY error is thrown, even unrelated ones
expect(() => createUser({ email: 'invalid' })).toThrow()
})
test('throws on missing required field', async () => {
// Catches network errors, type errors, anything
await expect(saveUser({})).rejects.toBeDefined()
})Correct (specific error assertions):
test('throws ValidationError for invalid email', () => {
expect(() => createUser({ email: 'invalid' }))
.toThrow(ValidationError)
})
test('error message indicates invalid email format', () => {
expect(() => createUser({ email: 'invalid' }))
.toThrow('Invalid email format')
})
test('throws with specific error details', () => {
expect(() => createUser({ email: 'invalid' }))
.toThrow(expect.objectContaining({
code: 'VALIDATION_ERROR',
field: 'email'
}))
})
test('async operation throws NotFoundError', async () => {
await expect(getUser('nonexistent'))
.rejects.toThrow(NotFoundError)
})
test('error includes resource identifier', async () => {
await expect(getUser('user-999'))
.rejects.toThrow(/user-999/)
})What to assert:
- Error class/type when using custom errors
- Error message content (exact or partial match)
- Error code or status when applicable
- Associated data (field name, invalid value)
Every Test Must Have Assertions
A test without assertions always passes, providing false confidence. Every test must verify expected outcomes through explicit assertions.
Incorrect (no assertions):
test('processes payment', async () => {
const order = createOrder({ total: 100 })
const payment = createPayment({ orderId: order.id, amount: 100 })
// Calls the function but doesn't verify anything
await paymentService.process(payment)
// Test passes even if process() does nothing
})
test('user registration flow', async () => {
const userData = { email: 'test@example.com', password: 'secret123' }
const user = await userService.register(userData)
await emailService.sendWelcome(user.id)
await analyticsService.trackSignup(user.id)
// Multiple operations, zero verification
// Could silently fail and test still passes
})Correct (explicit assertions):
test('processes payment and updates order status', async () => {
const order = createOrder({ total: 100, status: 'pending' })
const payment = createPayment({ orderId: order.id, amount: 100 })
await paymentService.process(payment)
const updatedOrder = await orderService.getById(order.id)
expect(updatedOrder.status).toBe('paid')
expect(updatedOrder.paidAt).toBeDefined()
})
test('registration creates user and sends welcome email', async () => {
const mockEmailService = { sendWelcome: jest.fn() }
const userService = new UserService({ emailService: mockEmailService })
const user = await userService.register({
email: 'test@example.com',
password: 'secret123'
})
expect(user.id).toBeDefined()
expect(user.email).toBe('test@example.com')
expect(mockEmailService.sendWelcome).toHaveBeenCalledWith(user.id)
})Common assertion-free antipatterns:
- "Smoke tests" that just call methods
- Tests that only set up data
- Tests that verify internal state through logging
Reference: Software Testing Anti-patterns - Codepipes
Use Snapshot Testing Judiciously
Snapshots are useful for detecting unintended changes but can become meaningless if overused. Use them for stable outputs and review changes carefully.
Incorrect (snapshot everything):
test('user service', () => {
const user = userService.create({ name: 'Alice', email: 'alice@test.com' })
// Snapshot includes timestamps, IDs - breaks on every run
expect(user).toMatchSnapshot()
})
test('renders user list', () => {
const component = render(<UserList users={mockUsers} />)
// 500-line snapshot that nobody reviews
expect(component).toMatchSnapshot()
})
// When snapshot fails, developer just runs `--updateSnapshot`
// without actually reviewing what changedCorrect (targeted snapshots):
test('user has expected structure', () => {
const user = userService.create({ name: 'Alice', email: 'alice@test.com' })
// Snapshot only stable parts
expect({
name: user.name,
email: user.email,
role: user.role
}).toMatchSnapshot()
})
test('error message format', () => {
const error = validateUser({ email: 'invalid' })
// Snapshots work well for error message text
expect(error.message).toMatchSnapshot()
})
// Prefer explicit assertions for behavior
test('renders correct number of users', () => {
const { getAllByRole } = render(<UserList users={mockUsers} />)
expect(getAllByRole('listitem')).toHaveLength(mockUsers.length)
})
// Inline snapshots for small, reviewable outputs
test('formats date correctly', () => {
const formatted = formatDate(new Date('2024-06-15'))
expect(formatted).toMatchInlineSnapshot(`"June 15, 2024"`)
})Snapshot best practices:
- Keep snapshots small and focused
- Use inline snapshots for short outputs
- Exclude non-deterministic values (IDs, timestamps)
- Review snapshot changes in code review
- Prefer explicit assertions for critical behavior
Reference: Snapshot Testing - Jest
Use Specific Assertions
Use the most specific assertion available for the check. Specific assertions provide better failure messages and document expected behavior more clearly.
Incorrect (generic assertions):
test('filters active users', () => {
const users = [
{ id: '1', active: true },
{ id: '2', active: false }
]
const result = filterActiveUsers(users)
// Generic - failure message: "expected true to be false"
expect(result.length === 1).toBe(true)
expect(result[0].id === '1').toBe(true)
})
test('user has expected properties', () => {
const user = getUser('123')
// Generic - unhelpful failure message
expect(user !== null).toBe(true)
expect(typeof user.email === 'string').toBe(true)
})Correct (specific assertions):
test('filters active users', () => {
const users = [
{ id: '1', active: true },
{ id: '2', active: false }
]
const result = filterActiveUsers(users)
// Specific - failure: "expected [array] to have length 1, got 0"
expect(result).toHaveLength(1)
// Specific - failure: "expected {id: '2'} to match {id: '1'}"
expect(result[0]).toMatchObject({ id: '1' })
})
test('user has expected properties', () => {
const user = getUser('123')
// Specific - failure: "expected null not to be null"
expect(user).not.toBeNull()
// Specific - failure: "expected 123 to be a string"
expect(user.email).toEqual(expect.any(String))
})Preferred matchers:
toHaveLength()over.length === ntoContain()overincludes() === truetoMatchObject()over checking each propertytoThrow()over try/catch with booleantoBeGreaterThan()over> comparison === true
Reference: Jest Expect API
Maintain a Test List
Before coding, write down all the test cases you can think of. Work through them one at a time. This prevents scope creep during implementation and ensures edge cases aren't forgotten.
Incorrect (ad-hoc test discovery):
// Start coding without a plan
test('parses valid JSON', () => {
expect(parseConfig('{"key": "value"}')).toEqual({ key: 'value' })
})
// Pass, move on
test('handles nested objects', () => {
expect(parseConfig('{"a": {"b": 1}}')).toEqual({ a: { b: 1 } })
})
// Pass, move on, forget about error cases
// Ship to production, crashes on invalid input
// Edge cases discovered by users, not testsCorrect (test list first):
/*
* Test List for parseConfig:
* [x] parses valid JSON object
* [x] parses nested objects
* [x] parses arrays
* [ ] throws on invalid JSON syntax
* [ ] throws on non-object root (array, string, number)
* [ ] handles empty object {}
* [ ] handles unicode characters
* [ ] handles escaped quotes in strings
*/
// Work through list systematically
test('parses valid JSON object', () => {
expect(parseConfig('{"key": "value"}')).toEqual({ key: 'value' })
})
test('throws on invalid JSON syntax', () => {
expect(() => parseConfig('{invalid}')).toThrow('Invalid JSON')
})
test('throws on non-object root', () => {
expect(() => parseConfig('[1, 2, 3]')).toThrow('Config must be an object')
})
// Add new cases to list as you discover them
// Cross off completed testsManaging the test list:
- Keep it visible (comment block, sticky note, or task tracker)
- Add new cases as you think of them during implementation
- Prioritize by risk and importance
- Don't remove items, mark them done
Reference: Test Driven Development by Kent Beck
Write Only Enough Code to Pass the Test
In the GREEN phase, write the absolute minimum code needed to make the failing test pass. Resist the urge to add features, optimizations, or "obvious" improvements not yet required by a test.
Incorrect (over-engineering in GREEN phase):
test('returns user by id', async () => {
const user = await userService.getById('user-123')
expect(user.id).toBe('user-123')
})
// Implementation does more than needed
class UserService {
private cache = new Map<string, User>()
private logger = new Logger()
async getById(id: string): Promise<User> {
// Caching not required by any test yet
if (this.cache.has(id)) {
this.logger.info('Cache hit', { id })
return this.cache.get(id)!
}
const user = await this.repository.findById(id)
this.cache.set(id, user)
this.logger.info('Cache miss', { id })
return user
}
}Correct (minimal implementation):
test('returns user by id', async () => {
const user = await userService.getById('user-123')
expect(user.id).toBe('user-123')
})
// Implementation does exactly what's needed
class UserService {
async getById(id: string): Promise<User> {
return this.repository.findById(id)
}
}
// Add caching only when a test requires itWhen to expand:
- Only when a new test requires additional behavior
- During the REFACTOR phase for structural improvements
- Never add untested features "while you're in there"
Reference: The Cycles of TDD - Clean Coder Blog
Refactor Immediately After Green
The REFACTOR phase is not optional. Once tests pass, immediately clean up both production and test code. Skipping refactoring accumulates technical debt that compounds over time.
Incorrect (skipping refactor phase):
// Test passes, move on to next feature
test('calculates order total with tax', () => {
const order = { items: [{ price: 100 }, { price: 50 }], taxRate: 0.1 }
expect(calculateTotal(order)).toBe(165)
})
// Quick and dirty implementation, "will clean up later"
function calculateTotal(order: Order): number {
let t = 0
for (let i = 0; i < order.items.length; i++) {
t = t + order.items[i].price
}
t = t + t * order.taxRate
return t
}
// Technical debt: unclear variable names, imperative styleCorrect (refactor while context is fresh):
test('calculates order total with tax', () => {
const order = { items: [{ price: 100 }, { price: 50 }], taxRate: 0.1 }
expect(calculateTotal(order)).toBe(165)
})
// After GREEN, immediately refactor
function calculateTotal(order: Order): number {
const subtotal = order.items.reduce((sum, item) => sum + item.price, 0)
const tax = subtotal * order.taxRate
return subtotal + tax
}
// Clean: descriptive names, functional style, clear intentThe refactor checklist:
- Rename unclear variables and functions
- Extract repeated code into functions
- Remove duplication in test setup
- Simplify complex conditionals
Note: Run tests after each refactoring step to ensure behavior is preserved.
Reference: Red-Green-Refactor - James Shore
Take Small Incremental Steps
Each red-green-refactor cycle should take seconds to minutes, not hours. Small steps provide rapid feedback, reduce debugging time, and make it easy to identify what broke.
Incorrect (giant leaps):
// One massive test covering entire feature
test('user registration with validation and email', async () => {
const result = await registerUser({
email: 'test@example.com',
password: 'SecurePass123!',
name: 'John Doe'
})
expect(result.success).toBe(true)
expect(result.user.email).toBe('test@example.com')
expect(result.user.emailVerified).toBe(false)
expect(emailService.sendVerification).toHaveBeenCalled()
expect(await database.users.findByEmail('test@example.com')).toBeDefined()
})
// Then write hundreds of lines to make it pass
// When it fails, unclear which part is brokenCorrect (baby steps):
// Step 1: User can be created
test('creates user with email and name', () => {
const user = createUser({ email: 'test@example.com', name: 'John' })
expect(user.email).toBe('test@example.com')
})
// Implement, refactor, commit
// Step 2: Password validation
test('rejects weak passwords', () => {
expect(() => createUser({
email: 'test@example.com',
password: '123'
})).toThrow('Password too weak')
})
// Implement, refactor, commit
// Step 3: Email verification flag
test('new users start with unverified email', () => {
const user = createUser({ email: 'test@example.com', name: 'John' })
expect(user.emailVerified).toBe(false)
})
// Implement, refactor, commit
// Each step: ~30 seconds to 2 minutesBenefits of small steps:
- Failures are immediately traceable to last change
- Easier to maintain focus and flow
- Can commit after each passing cycle
- Natural breakpoints for review or pause
Reference: The Cycles of TDD - Clean Coder Blog
Verify the Test Fails Before Writing Code
Always run your new test and watch it fail before writing implementation code. A test that passes immediately either tests nothing meaningful or the feature already exists.
Incorrect (never seeing red):
// Write test
test('validates email format', () => {
expect(isValidEmail('user@example.com')).toBe(true)
})
// Immediately write implementation without running test
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// Run tests - passes, but did the test ever fail?
// Could be testing the wrong function or have a typoCorrect (verify RED before GREEN):
// Step 1: Write test
test('validates email format', () => {
expect(isValidEmail('user@example.com')).toBe(true)
})
// Step 2: Run test - see it fail
// Error: isValidEmail is not defined
// This confirms the test is wired up correctly
// Step 3: Write minimal stub
function isValidEmail(email: string): boolean {
return false
}
// Step 4: Run test - see it fail with correct assertion
// Expected: true, Received: false
// This confirms the assertion is testing the right thing
// Step 5: Implement
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// Step 6: Run test - see it pass (GREEN)Why this matters:
- Confirms test is actually running
- Validates the assertion checks what you intend
- Catches copy-paste errors from other tests
- Proves the test can detect failure
Reference: Test Driven Development - Martin Fowler
Write the Test Before the Implementation
Writing the test first forces you to think about the API design and expected behavior before writing production code. This leads to better interfaces and catches design issues early.
Incorrect (implementation first, test as afterthought):
// 1. Write implementation first
function calculateDiscount(price: number, customerType: string): number {
if (customerType === 'premium') {
return price * 0.2
}
if (customerType === 'regular') {
return price * 0.1
}
return 0
}
// 2. Write test after (often skipped or superficial)
test('calculateDiscount works', () => {
expect(calculateDiscount(100, 'premium')).toBe(20)
})
// Edge cases forgotten, API already locked inCorrect (test first, implementation follows):
// 1. Write failing test first (RED)
describe('calculateDiscount', () => {
it('applies 20% discount for premium customers', () => {
expect(calculateDiscount(100, 'premium')).toBe(20)
})
it('applies 10% discount for regular customers', () => {
expect(calculateDiscount(100, 'regular')).toBe(10)
})
it('returns zero discount for unknown customer types', () => {
expect(calculateDiscount(100, 'unknown')).toBe(0)
})
})
// 2. Write minimal implementation to pass (GREEN)
function calculateDiscount(price: number, customerType: string): number {
const discounts: Record<string, number> = { premium: 0.2, regular: 0.1 }
return price * (discounts[customerType] ?? 0)
}Benefits:
- Forces consideration of edge cases before implementation
- Results in more testable, better-designed APIs
- Ensures tests actually verify behavior, not just exercise code
Reference: Test Driven Development by Kent Beck
Avoid Mystery Guests
All test data should be visible within the test or clearly referenced. Hidden data loaded from fixtures or external files makes tests impossible to understand in isolation.
Incorrect (mystery guest from fixtures):
// fixtures/users.json - somewhere else in the codebase
// { "testUser": { "id": "u1", "role": "admin", "permissions": ["read", "write", "delete"] } }
test('admin can delete posts', async () => {
// Where does testUser come from? What role? What permissions?
const result = await deletePost('post-123', fixtures.testUser)
expect(result.success).toBe(true)
})
test('user permissions are checked', async () => {
// Reader must hunt through fixture files to understand
await expect(deletePost('post-123', fixtures.regularUser))
.rejects.toThrow('Forbidden')
})Correct (data visible in test):
test('admin can delete posts', async () => {
// All relevant information visible
const admin = createUser({ role: 'admin', permissions: ['delete'] })
const result = await deletePost('post-123', admin)
expect(result.success).toBe(true)
})
test('users without delete permission cannot delete posts', async () => {
const user = createUser({ role: 'member', permissions: ['read'] })
await expect(deletePost('post-123', user))
.rejects.toThrow('Forbidden')
})When fixtures are acceptable:
- Reference data that never changes (country codes, currencies)
- Large datasets for performance testing
- Seed data for integration tests (clearly documented)
Signs of mystery guests:
- Test fails and you have to search for data definitions
- Changing a fixture breaks unrelated tests
- Test name doesn't explain why a particular fixture is used
Reference: Software Testing Anti-patterns - Codepipes
Use Builder Pattern for Complex Objects
For objects with many optional fields or complex construction, use the builder pattern to create readable, flexible test setup.
Incorrect (unwieldy object construction):
test('processes order with all options', () => {
const order = {
id: '123',
customer: { id: 'c1', name: 'Alice', tier: 'premium' },
items: [
{ productId: 'p1', quantity: 2, price: 100 },
{ productId: 'p2', quantity: 1, price: 50 }
],
shipping: { method: 'express', address: { city: 'NYC', zip: '10001' } },
payment: { method: 'card', cardLast4: '1234' },
discount: { code: 'SAVE10', percent: 10 },
giftWrap: true,
giftMessage: 'Happy Birthday!'
}
const result = processOrder(order)
expect(result.total).toBe(225)
})Correct (fluent builder):
// test-utils/order-builder.ts
class OrderBuilder {
private order: Partial<Order> = {}
withCustomer(tier: 'basic' | 'premium' = 'basic'): this {
this.order.customer = createCustomer({ tier })
return this
}
withItems(...items: Array<{ price: number; quantity?: number }>): this {
this.order.items = items.map(item =>
createOrderItem({ price: item.price, quantity: item.quantity ?? 1 })
)
return this
}
withDiscount(percent: number): this {
this.order.discount = { code: 'TEST', percent }
return this
}
withExpressShipping(): this {
this.order.shipping = { method: 'express', address: createAddress() }
return this
}
asGift(message: string): this {
this.order.giftWrap = true
this.order.giftMessage = message
return this
}
build(): Order {
return createOrder(this.order)
}
}
const anOrder = () => new OrderBuilder()
// Clean, readable test
test('processes order with all options', () => {
const order = anOrder()
.withCustomer('premium')
.withItems({ price: 100, quantity: 2 }, { price: 50 })
.withDiscount(10)
.withExpressShipping()
.asGift('Happy Birthday!')
.build()
const result = processOrder(order)
expect(result.total).toBe(225)
})When to use builders:
- Objects with 5+ optional fields
- Complex nested structures
- Multiple valid configurations
- When tests need different combinations of options
Reference: Effective tests: Creating test data - Dave Development
Keep Test Setup Minimal
Include only the data necessary for the specific test. Excessive setup obscures the test's purpose and slows execution.
Incorrect (excessive setup):
test('validates email format', () => {
// Full user object when only email matters
const user = {
id: '123',
firstName: 'John',
lastName: 'Doe',
email: 'invalid-email',
dateOfBirth: new Date('1990-01-01'),
address: {
street: '123 Main St',
city: 'Springfield',
state: 'IL',
zipCode: '12345'
},
preferences: {
newsletter: true,
notifications: { email: true, sms: false }
},
createdAt: new Date(),
updatedAt: new Date()
}
const errors = validateUser(user)
expect(errors).toContain('Invalid email format')
})Correct (minimal setup):
test('validates email format', () => {
// Only email is relevant to this test
const user = createUser({ email: 'invalid-email' })
const errors = validateUser(user)
expect(errors).toContain('Invalid email format')
})
// Or even simpler if testing just the email validator
test('rejects email without @ symbol', () => {
const result = isValidEmail('invalidemail')
expect(result).toBe(false)
})Guidelines:
- If a property isn't in the test name, question whether it's needed
- Let factories provide default values for irrelevant properties
- Prefer testing smaller units that need less setup
- Complex setup often indicates design issues in production code
Reference: Rails Testing Antipatterns - Semaphore
Use Unique Identifiers Per Test
Generate unique IDs for test entities to prevent conflicts between tests running in parallel or sharing a database.
Incorrect (hard-coded IDs):
test('creates user', async () => {
await userService.create({ id: 'user-1', email: 'test@example.com' })
const user = await userService.getById('user-1')
expect(user.email).toBe('test@example.com')
})
test('updates user', async () => {
// Uses same ID - fails if tests run in parallel or wrong order
await userService.create({ id: 'user-1', email: 'test@example.com' })
await userService.update('user-1', { email: 'new@example.com' })
const user = await userService.getById('user-1')
expect(user.email).toBe('new@example.com')
})
test('deletes user', async () => {
// May delete user from other test
await userService.delete('user-1')
expect(await userService.getById('user-1')).toBeNull()
})Correct (unique IDs):
function uniqueId(prefix: string = ''): string {
return `${prefix}${Date.now()}-${Math.random().toString(36).slice(2)}`
}
test('creates user', async () => {
const userId = uniqueId('user-')
const email = `${uniqueId()}@example.com`
await userService.create({ id: userId, email })
const user = await userService.getById(userId)
expect(user.email).toBe(email)
})
test('updates user', async () => {
const userId = uniqueId('user-')
await userService.create({ id: userId, email: 'original@example.com' })
await userService.update(userId, { email: 'updated@example.com' })
const user = await userService.getById(userId)
expect(user.email).toBe('updated@example.com')
})
// Or use factory that generates unique IDs automatically
test('deletes user', async () => {
const user = await createAndSaveUser()
await userService.delete(user.id)
expect(await userService.getById(user.id)).toBeNull()
})Benefits:
- Tests can run in any order
- Tests can run in parallel
- No cleanup needed between tests
- Failures are isolated to single test
Reference: How to deal with flaky tests - Semaphore
Use Factories for Test Data Creation
Create factory functions that generate test objects with sensible defaults. Override only the properties relevant to each test, keeping setup minimal and focused.
Incorrect (verbose inline object creation):
test('calculates order total with discount', () => {
const order = {
id: '123',
userId: 'user-456',
items: [
{ id: 'item-1', name: 'Widget', price: 100, quantity: 2 },
{ id: 'item-2', name: 'Gadget', price: 50, quantity: 1 }
],
discount: 0.1,
status: 'pending',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
shippingAddress: {
street: '123 Main St',
city: 'Springfield',
zipCode: '12345',
country: 'USA'
}
}
// 20 lines of setup for a test about discount calculation
expect(calculateTotal(order)).toBe(225) // (200 + 50) * 0.9
})Correct (factory with relevant overrides):
// factories/order.ts
function createOrder(overrides: Partial<Order> = {}): Order {
return {
id: `order-${Math.random().toString(36).slice(2)}`,
userId: 'default-user',
items: [],
discount: 0,
status: 'pending',
createdAt: new Date(),
updatedAt: new Date(),
shippingAddress: createAddress(),
...overrides
}
}
function createOrderItem(overrides: Partial<OrderItem> = {}): OrderItem {
return {
id: `item-${Math.random().toString(36).slice(2)}`,
name: 'Test Product',
price: 10,
quantity: 1,
...overrides
}
}
// Test focuses only on relevant data
test('calculates order total with discount', () => {
const order = createOrder({
items: [
createOrderItem({ price: 100, quantity: 2 }),
createOrderItem({ price: 50, quantity: 1 })
],
discount: 0.1
})
expect(calculateTotal(order)).toBe(225)
})Benefits:
- Tests show only relevant data
- Single place to update when model changes
- Consistent default values across tests
- Readable test intent
Reference: Test Factories - Radan Skoric
Follow the Arrange-Act-Assert Pattern
Structure every test with three distinct phases: Arrange (setup), Act (execute), Assert (verify). This pattern makes tests predictable and easy to understand.
Incorrect (phases interleaved):
test('processes order', async () => {
const user = createUser()
expect(user.orders).toHaveLength(0) // Assert before Act
const product = createProduct({ price: 100 })
await orderService.addToCart(user.id, product.id) // Act 1
expect(await cartService.getItems(user.id)).toHaveLength(1) // Assert 1
const order = await orderService.checkout(user.id) // Act 2
expect(order.total).toBe(100) // Assert 2
expect(order.status).toBe('pending') // Assert 2 continued
await paymentService.process(order.id) // Act 3
expect(order.status).toBe('paid') // Assert 3
})Correct (clear AAA structure):
test('checkout creates order with cart total', async () => {
// Arrange
const user = await createUser()
const product = await createProduct({ price: 100 })
await cartService.addItem(user.id, product.id)
// Act
const order = await orderService.checkout(user.id)
// Assert
expect(order.total).toBe(100)
expect(order.status).toBe('pending')
})
test('processPayment marks order as paid', async () => {
// Arrange
const order = await createOrder({ status: 'pending', total: 100 })
// Act
await paymentService.process(order.id)
// Assert
const updated = await orderService.getById(order.id)
expect(updated.status).toBe('paid')
})Guidelines:
- One Act per test (single method call or user action)
- Assert only the outcomes of that specific Act
- Blank lines between sections improve readability
- Comments (
// Arrange,// Act,// Assert) are optional but helpful
Reference: AAA Pattern in Unit Testing - Semaphore
Avoid Logic in Tests
Tests should be straightforward sequences of setup, action, and verification. Conditionals, loops, and complex calculations in tests can contain bugs, making tests unreliable.
Incorrect (logic in tests):
test('calculates correct totals for all order types', () => {
const orderTypes = ['standard', 'express', 'overnight']
const expectedMultipliers = [1, 1.5, 2.5]
for (let i = 0; i < orderTypes.length; i++) {
const order = createOrder({ type: orderTypes[i], basePrice: 100 })
const total = calculateShipping(order)
// Bug: if expectedMultipliers array is wrong, test passes bad code
expect(total).toBe(100 * expectedMultipliers[i])
}
})
test('filters active users', () => {
const users = createUsers(10)
const activeUsers = users.filter(u => u.isActive) // Logic in test!
const result = filterActiveUsers(users)
// If filter logic is wrong in both places, test passes
expect(result).toEqual(activeUsers)
})Correct (explicit, linear tests):
test('standard shipping uses base price', () => {
const order = createOrder({ type: 'standard', basePrice: 100 })
const total = calculateShipping(order)
expect(total).toBe(100)
})
test('express shipping adds 50% surcharge', () => {
const order = createOrder({ type: 'express', basePrice: 100 })
const total = calculateShipping(order)
expect(total).toBe(150)
})
test('overnight shipping adds 150% surcharge', () => {
const order = createOrder({ type: 'overnight', basePrice: 100 })
const total = calculateShipping(order)
expect(total).toBe(250)
})
test('filterActiveUsers returns only active users', () => {
const activeUser = createUser({ isActive: true })
const inactiveUser = createUser({ isActive: false })
const result = filterActiveUsers([activeUser, inactiveUser])
expect(result).toEqual([activeUser])
})Exceptions:
- Parameterized tests with test framework support (e.g.,
test.each) - Simple array literals for multiple assertions on same object
Use Descriptive Test Names
Test names should describe the scenario and expected outcome so clearly that you understand what broke without reading the test code.
Incorrect (vague or technical names):
test('test1', () => { /* ... */ })
test('calculator', () => { /* ... */ })
test('divide', () => { /* ... */ })
test('divideByZeroTest', () => { /* ... */ })
test('should work correctly', () => { /* ... */ })Correct (scenario and outcome in name):
// Pattern: [unit]_[scenario]_[expectedBehavior]
test('divide_positiveNumbers_returnsQuotient', () => {
expect(divide(10, 2)).toBe(5)
})
test('divide_byZero_throwsDivisionError', () => {
expect(() => divide(10, 0)).toThrow(DivisionError)
})
// Pattern: "should [outcome] when [condition]"
test('should return empty array when no users match filter', () => {
const result = filterUsers(users, { role: 'nonexistent' })
expect(result).toEqual([])
})
// Pattern: "it [does something]"
describe('ShoppingCart', () => {
describe('addItem', () => {
it('increases total by item price', () => { /* ... */ })
it('increments item count', () => { /* ... */ })
it('throws when item is out of stock', () => { /* ... */ })
})
})Good test names answer:
- What is being tested?
- Under what conditions?
- What is the expected result?
Benefits:
- Failed tests are immediately understandable
- Test suite serves as living documentation
- Easy to identify missing test cases
Reference: Unit Test Naming Conventions - TheCodeBuzz
One Logical Assertion Per Test
Each test should verify one logical concept. When a test fails, you should know exactly what's broken without reading the test body.
Incorrect (multiple unrelated assertions):
test('user service', async () => {
const user = await userService.create({ name: 'Alice', email: 'alice@test.com' })
expect(user.id).toBeDefined()
expect(user.name).toBe('Alice')
expect(user.email).toBe('alice@test.com')
expect(user.createdAt).toBeInstanceOf(Date)
const fetched = await userService.getById(user.id)
expect(fetched).toEqual(user)
await userService.delete(user.id)
expect(await userService.getById(user.id)).toBeNull()
})
// If this fails, which operation broke?Correct (one concept per test):
describe('UserService', () => {
describe('create', () => {
it('generates a unique id', async () => {
const user = await userService.create({ name: 'Alice', email: 'alice@test.com' })
expect(user.id).toBeDefined()
})
it('stores provided name and email', async () => {
const user = await userService.create({ name: 'Alice', email: 'alice@test.com' })
expect(user).toMatchObject({ name: 'Alice', email: 'alice@test.com' })
})
it('sets createdAt to current time', async () => {
const before = new Date()
const user = await userService.create({ name: 'Alice', email: 'alice@test.com' })
expect(user.createdAt.getTime()).toBeGreaterThanOrEqual(before.getTime())
})
})
describe('getById', () => {
it('returns previously created user', async () => {
const created = await userService.create({ name: 'Alice', email: 'alice@test.com' })
const fetched = await userService.getById(created.id)
expect(fetched).toEqual(created)
})
})
})Note: Multiple expect statements are fine when they verify the same logical assertion (e.g., checking multiple properties of a single return value).
Test Behavior Not Implementation
Tests should verify what the code does (behavior), not how it does it (implementation). Implementation-coupled tests break during refactoring even when behavior is preserved.
Incorrect (testing implementation details):
test('sortUsers calls quicksort with correct comparator', () => {
const quicksortSpy = jest.spyOn(sortUtils, 'quicksort')
sortUsers(users, 'name')
expect(quicksortSpy).toHaveBeenCalledWith(
users,
expect.any(Function)
)
// Breaks if we switch to mergesort, even though behavior is identical
})
test('caches user in internal Map', () => {
const service = new UserService()
service.getUser('123')
// Testing private implementation detail
expect(service['cache'].has('123')).toBe(true)
// Breaks if we change cache structure
})Correct (testing observable behavior):
test('sortUsers returns users ordered by name', () => {
const users = [
{ name: 'Charlie', id: '1' },
{ name: 'Alice', id: '2' },
{ name: 'Bob', id: '3' }
]
const sorted = sortUsers(users, 'name')
expect(sorted.map(u => u.name)).toEqual(['Alice', 'Bob', 'Charlie'])
// Passes regardless of sorting algorithm used
})
test('getUser returns same instance on repeated calls', () => {
const service = new UserService()
const first = await service.getUser('123')
const second = await service.getUser('123')
expect(first).toBe(second)
// Tests caching behavior without knowing how it's implemented
})Ask yourself:
- Would this test break if I refactored the internals?
- Am I testing public API or private implementation?
- Does this test document what users of this code care about?
Test Edge Cases and Boundaries
Happy path tests alone miss most bugs. Explicitly test boundaries, empty states, error conditions, and unusual inputs where bugs typically hide.
Incorrect (happy path only):
test('paginates results', () => {
const items = createItems(100)
const result = paginate(items, { page: 1, pageSize: 10 })
expect(result.items).toHaveLength(10)
expect(result.totalPages).toBe(10)
})
// Works for normal case, crashes in production on edge casesCorrect (comprehensive edge case coverage):
describe('paginate', () => {
// Happy path
it('returns requested page of items', () => {
const items = createItems(100)
const result = paginate(items, { page: 2, pageSize: 10 })
expect(result.items).toHaveLength(10)
expect(result.currentPage).toBe(2)
})
// Empty state
it('returns empty array when no items exist', () => {
const result = paginate([], { page: 1, pageSize: 10 })
expect(result.items).toEqual([])
expect(result.totalPages).toBe(0)
})
// Boundary: last page partial
it('returns partial page when items dont fill last page', () => {
const items = createItems(25)
const result = paginate(items, { page: 3, pageSize: 10 })
expect(result.items).toHaveLength(5)
})
// Boundary: page beyond range
it('returns empty array when page exceeds total pages', () => {
const items = createItems(10)
const result = paginate(items, { page: 5, pageSize: 10 })
expect(result.items).toEqual([])
})
// Invalid input
it('throws when page is zero or negative', () => {
expect(() => paginate([], { page: 0, pageSize: 10 })).toThrow()
expect(() => paginate([], { page: -1, pageSize: 10 })).toThrow()
})
// Boundary: single item
it('handles single item correctly', () => {
const items = createItems(1)
const result = paginate(items, { page: 1, pageSize: 10 })
expect(result.items).toHaveLength(1)
expect(result.totalPages).toBe(1)
})
})Edge case checklist:
- Empty collections
- Single item
- Maximum values
- Zero and negative numbers
- Null/undefined inputs
- Boundary conditions (off-by-one)
- Concurrent access
Reference: Test Driven Development by Kent Beck
Write Deterministic Tests
Tests must produce the same result every time when code hasn't changed. Non-deterministic tests erode trust and get ignored.
Incorrect (non-deterministic tests):
test('generates unique order id', () => {
const order = createOrder()
// Depends on current time - flaky around midnight
expect(order.id).toMatch(/^ORD-2024-/)
})
test('token expires in future', () => {
const token = generateToken()
// Race condition: might fail if test runs at exact boundary
expect(token.expiresAt.getTime()).toBeGreaterThan(Date.now())
})
test('shuffles items randomly', () => {
const items = [1, 2, 3, 4, 5]
const shuffled = shuffle(items)
// Non-deterministic: might be same order by chance
expect(shuffled).not.toEqual(items)
})Correct (deterministic tests):
test('generates unique order id with date prefix', () => {
// Inject fixed clock
const fixedDate = new Date('2024-06-15T10:30:00Z')
jest.useFakeTimers().setSystemTime(fixedDate)
const order = createOrder()
expect(order.id).toMatch(/^ORD-2024-06-15-/)
jest.useRealTimers()
})
test('token expires 1 hour after creation', () => {
const fixedNow = new Date('2024-06-15T10:00:00Z')
jest.useFakeTimers().setSystemTime(fixedNow)
const token = generateToken()
const expectedExpiry = new Date('2024-06-15T11:00:00Z')
expect(token.expiresAt).toEqual(expectedExpiry)
jest.useRealTimers()
})
test('shuffle changes element positions', () => {
// Seed random number generator for reproducibility
const shuffler = createShuffler({ seed: 12345 })
const items = [1, 2, 3, 4, 5]
const shuffled = shuffler.shuffle(items)
// Deterministic with seeded RNG
expect(shuffled).toEqual([3, 1, 5, 2, 4])
})Sources of non-determinism to control:
- Current time/date
- Random number generation
- External API responses
- File system state
- Network latency
- Parallel execution order
Reference: Flaky Tests - Datadog
Mock External Dependencies
Replace external systems (databases, APIs, file systems) with test doubles. This makes tests fast, deterministic, and independent of external state.
Incorrect (using real external dependencies):
test('sends welcome email to new user', async () => {
const user = await userService.register({
email: 'test@example.com',
name: 'Alice'
})
// Actually sends email - slow, unreliable, costs money
// Fails if email server is down
// Clutters real inbox or spam folder
const emails = await checkEmailInbox('test@example.com')
expect(emails).toContainEqual(expect.objectContaining({
subject: 'Welcome to our platform!'
}))
})Correct (mock external dependency):
test('sends welcome email to new user', async () => {
// Arrange
const mockEmailService = {
send: jest.fn().mockResolvedValue({ success: true })
}
const userService = new UserService({ emailService: mockEmailService })
// Act
await userService.register({
email: 'test@example.com',
name: 'Alice'
})
// Assert
expect(mockEmailService.send).toHaveBeenCalledWith({
to: 'test@example.com',
subject: 'Welcome to our platform!',
body: expect.stringContaining('Alice')
})
})What to mock:
- HTTP APIs and network calls
- Databases
- File system operations
- Email/SMS services
- Third-party SDKs
- System clock and randomness
What NOT to mock:
- The code under test itself
- Simple value objects
- Pure utility functions
Reference: Isolating Dependencies - Code Magazine
Avoid Shared Mutable State Between Tests
Each test must run in isolation without depending on or affecting other tests. Shared state causes tests to pass or fail based on execution order.
Incorrect (shared mutable state):
// Shared across all tests in file
let userCount = 0
let database: User[] = []
test('creates first user', () => {
const user = createUser({ name: 'Alice' })
database.push(user)
userCount++
expect(database).toHaveLength(1)
expect(userCount).toBe(1)
})
test('creates second user', () => {
const user = createUser({ name: 'Bob' })
database.push(user)
userCount++
// Fails if tests run in different order or parallel
expect(database).toHaveLength(2)
expect(userCount).toBe(2)
})Correct (isolated state per test):
describe('createUser', () => {
let database: User[]
beforeEach(() => {
// Fresh state for each test
database = []
})
test('creates first user', () => {
const user = createUser({ name: 'Alice' })
database.push(user)
expect(database).toHaveLength(1)
})
test('creates second user', () => {
const user = createUser({ name: 'Bob' })
database.push(user)
// Always passes regardless of test order
expect(database).toHaveLength(1)
})
})Isolation techniques:
- Reset state in
beforeEach - Use unique identifiers per test (e.g., UUID)
- Wrap tests in database transactions that rollback
- Create fresh instances instead of reusing singletons
Reference: Software Testing Anti-patterns - Codepipes
Prefer Stubs Over Mocks for Queries
Use stubs (return canned responses) for methods that return data. Reserve mocks (verify interactions) for methods that perform actions. Over-mocking leads to brittle tests.
Incorrect (mocking everything):
test('displays user profile', async () => {
const mockUserService = {
getById: jest.fn().mockResolvedValue({ id: '123', name: 'Alice' }),
getPreferences: jest.fn().mockResolvedValue({ theme: 'dark' }),
getAvatar: jest.fn().mockResolvedValue('/avatar.png')
}
await renderProfile('123', mockUserService)
// Verifying query calls creates coupling to implementation
expect(mockUserService.getById).toHaveBeenCalledWith('123')
expect(mockUserService.getPreferences).toHaveBeenCalledWith('123')
expect(mockUserService.getAvatar).toHaveBeenCalledWith('123')
// Test breaks if we batch these calls or change call order
})Correct (stubs for queries, mocks for commands):
test('displays user profile with preferences', async () => {
// Stub: just provide data, don't verify calls
const userService = {
getById: async () => ({ id: '123', name: 'Alice' }),
getPreferences: async () => ({ theme: 'dark' }),
getAvatar: async () => '/avatar.png'
}
const { getByText, getByRole } = await renderProfile('123', userService)
// Assert on observable output, not internal calls
expect(getByText('Alice')).toBeInTheDocument()
expect(document.body).toHaveClass('theme-dark')
})
test('saves updated preferences', async () => {
// Mock: verify the command was called correctly
const mockUserService = {
getById: async () => ({ id: '123', name: 'Alice' }),
getPreferences: async () => ({ theme: 'dark' }),
savePreferences: jest.fn().mockResolvedValue({ success: true })
}
await updateTheme('123', 'light', mockUserService)
// Commands should be verified
expect(mockUserService.savePreferences).toHaveBeenCalledWith('123', {
theme: 'light'
})
})Guidelines:
- Queries (return data, no side effects): Use stubs
- Commands (perform actions, have side effects): Use mocks
- When in doubt, stub it
Reference: Mocks Aren't Stubs - Martin Fowler
Use Dependency Injection for Testability
Design code to receive dependencies through constructor or method parameters rather than creating them internally. This enables easy substitution of test doubles.
Incorrect (hard-coded dependencies):
class OrderService {
async createOrder(items: Item[]): Promise<Order> {
// Hard-coded dependency - impossible to test without real database
const db = new DatabaseConnection()
const order = await db.insert('orders', { items })
// Hard-coded dependency - actually sends emails during tests
const emailer = new EmailService()
await emailer.send(order.userEmail, 'Order confirmed')
return order
}
}
// Test requires real database and email service
test('creates order', async () => {
const service = new OrderService()
const order = await service.createOrder([{ id: '1', price: 100 }])
expect(order).toBeDefined()
// Flaky, slow, sends real emails
})Correct (dependencies injected):
interface Database {
insert(table: string, data: unknown): Promise<{ id: string }>
}
interface Emailer {
send(to: string, subject: string): Promise<void>
}
class OrderService {
constructor(
private db: Database,
private emailer: Emailer
) {}
async createOrder(items: Item[]): Promise<Order> {
const order = await this.db.insert('orders', { items })
await this.emailer.send(order.userEmail, 'Order confirmed')
return order
}
}
// Test with injected fakes
test('creates order and sends confirmation', async () => {
const fakeDb: Database = {
insert: jest.fn().mockResolvedValue({
id: 'order-123',
userEmail: 'test@example.com'
})
}
const fakeEmailer: Emailer = {
send: jest.fn().mockResolvedValue(undefined)
}
const service = new OrderService(fakeDb, fakeEmailer)
const order = await service.createOrder([{ id: '1', price: 100 }])
expect(order.id).toBe('order-123')
expect(fakeEmailer.send).toHaveBeenCalledWith(
'test@example.com',
'Order confirmed'
)
})Benefits:
- Tests run without real infrastructure
- Easy to test error conditions
- Clear dependencies in constructor
- Production code receives real implementations
Reference: Dependency Injection - Tao of Testing
Follow Consistent Test File Structure
Establish and follow a consistent pattern for test file location and naming. Developers should instantly know where to find tests for any code.
Incorrect (inconsistent structure):
src/
services/
userService.ts
tests/
userService.spec.ts
models/
user.ts
tests/
models/
user.test.ts
spec/
integration/
user_tests.js
__tests__/
userStuff.tsCorrect (consistent co-located tests):
src/
services/
userService.ts
userService.test.ts # Unit tests next to source
models/
user.ts
user.test.ts
components/
UserProfile.tsx
UserProfile.test.tsx
tests/
integration/ # Integration tests separate
user-registration.test.ts
checkout-flow.test.ts
e2e/ # E2E tests separate
user-journey.test.tsAlternative (mirror structure):
src/
services/
userService.ts
models/
user.ts
tests/
unit/
services/
userService.test.ts # Mirrors src/ structure
models/
user.test.ts
integration/
user-registration.test.tsNaming conventions:
*.test.tsor*.spec.ts- pick one, use consistently- Match source file name:
userService.ts→userService.test.ts - Use descriptive integration test names:
checkout-flow.test.ts
Reference: Jest Configuration - testMatch
Group Tests by Behavior Not Method
Organize tests around features and behaviors that users care about, not around implementation methods. This makes tests serve as documentation.
Incorrect (grouped by method):
describe('UserService', () => {
describe('create', () => {
test('test 1', () => { /* ... */ })
test('test 2', () => { /* ... */ })
test('test 3', () => { /* ... */ })
})
describe('update', () => {
test('test 1', () => { /* ... */ })
test('test 2', () => { /* ... */ })
})
describe('delete', () => {
test('test 1', () => { /* ... */ })
})
})
// Reader doesn't know what behaviors are being testedCorrect (grouped by behavior):
describe('UserService', () => {
describe('user registration', () => {
it('creates user with provided email and name', () => { /* ... */ })
it('generates unique user ID', () => { /* ... */ })
it('sends welcome email to new user', () => { /* ... */ })
it('rejects duplicate email addresses', () => { /* ... */ })
it('validates email format', () => { /* ... */ })
})
describe('profile updates', () => {
it('updates user name', () => { /* ... */ })
it('validates new email before update', () => { /* ... */ })
it('sends verification email when email changes', () => { /* ... */ })
it('preserves unchanged fields', () => { /* ... */ })
})
describe('account deletion', () => {
it('removes user data', () => { /* ... */ })
it('cancels active subscriptions', () => { /* ... */ })
it('sends confirmation email', () => { /* ... */ })
})
})
// Tests read like feature documentationBenefits:
- Tests document features, not implementation
- Easy to find tests for specific behaviors
- Missing behaviors become obvious
- Refactoring methods doesn't require reorganizing tests
Reference: BDD and the Given-When-Then pattern
Use Parameterized Tests for Variations
When testing the same behavior with different inputs, use parameterized tests instead of duplicating test code.
Incorrect (duplicated tests):
test('validates email: missing @', () => {
expect(isValidEmail('userexample.com')).toBe(false)
})
test('validates email: missing domain', () => {
expect(isValidEmail('user@')).toBe(false)
})
test('validates email: missing local part', () => {
expect(isValidEmail('@example.com')).toBe(false)
})
test('validates email: valid simple', () => {
expect(isValidEmail('user@example.com')).toBe(true)
})
test('validates email: valid with dots', () => {
expect(isValidEmail('user.name@example.com')).toBe(true)
})
// 5 tests with identical structureCorrect (parameterized tests):
describe('isValidEmail', () => {
it.each([
['user@example.com', true, 'simple valid email'],
['user.name@example.com', true, 'email with dots'],
['user+tag@example.com', true, 'email with plus tag'],
['userexample.com', false, 'missing @ symbol'],
['user@', false, 'missing domain'],
['@example.com', false, 'missing local part'],
['user@@example.com', false, 'double @ symbol'],
['user@.com', false, 'domain starts with dot'],
])('returns %s for %s (%s)', (email, expected, _description) => {
expect(isValidEmail(email)).toBe(expected)
})
})
// Alternative with table syntax
describe('calculateShipping', () => {
it.each`
weight | distance | expected | description
${1} | ${10} | ${5.00} | ${'light, short distance'}
${1} | ${100} | ${10.00} | ${'light, long distance'}
${10} | ${10} | ${15.00} | ${'heavy, short distance'}
${10} | ${100} | ${25.00} | ${'heavy, long distance'}
`('costs $expected for $description', ({ weight, distance, expected }) => {
expect(calculateShipping(weight, distance)).toBe(expected)
})
})When to parameterize:
- Same function with different inputs
- Boundary value testing
- Validation rules with multiple cases
- Format conversions
When NOT to parameterize:
- Different setup or behavior per case
- When it obscures what's being tested
Reference: Jest test.each
Use Setup and Teardown Hooks Appropriately
Use beforeEach/afterEach for common setup that applies to all tests in a block. Avoid hooks when they obscure test behavior or create hidden dependencies.
Incorrect (hooks hide test behavior):
describe('OrderService', () => {
let service: OrderService
let user: User
let product: Product
let cart: Cart
beforeEach(async () => {
service = new OrderService()
user = await createUser({ tier: 'premium' }) // Why premium?
product = await createProduct({ price: 100 }) // Why 100?
cart = await createCart({ userId: user.id })
await cart.addItem(product.id, 2) // Why 2 items?
})
test('calculates total', () => {
// Reader must check beforeEach to understand test
const total = service.calculateTotal(cart)
expect(total).toBe(200) // Unexplained number, unclear where it comes from
})
})Correct (hooks for infrastructure, tests show data):
describe('OrderService', () => {
let service: OrderService
// Hook for infrastructure only
beforeEach(() => {
service = new OrderService()
})
afterEach(async () => {
await cleanupTestOrders()
})
test('calculates total from item prices and quantities', () => {
// Test shows all relevant data
const cart = createCart({
items: [
{ productId: 'p1', price: 100, quantity: 2 },
{ productId: 'p2', price: 50, quantity: 1 }
]
})
const total = service.calculateTotal(cart)
expect(total).toBe(250) // 100*2 + 50*1, reader can verify
})
test('applies premium discount', () => {
const cart = createCart({
items: [{ productId: 'p1', price: 100, quantity: 1 }],
userTier: 'premium' // Explicit: test is about premium discount
})
const total = service.calculateTotal(cart)
expect(total).toBe(90) // 10% premium discount
})
})Guidelines:
- Use hooks for: service instantiation, database cleanup, mock resets
- Avoid hooks for: test-specific data, scenario setup
- If a test needs different setup, create a nested describe block
Reference: Jest Setup and Teardown
Extract Reusable Test Utilities
Create shared test utilities for common operations. Keep them in a dedicated location so all tests can use consistent patterns.
Incorrect (duplicated test code):
// user.test.ts
test('creates user', async () => {
const response = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${await getTestToken()}`)
.set('Content-Type', 'application/json')
.send({ email: 'test@example.com', name: 'Test' })
expect(response.status).toBe(201)
})
// order.test.ts
test('creates order', async () => {
// Same boilerplate repeated
const response = await request(app)
.post('/api/orders')
.set('Authorization', `Bearer ${await getTestToken()}`)
.set('Content-Type', 'application/json')
.send({ items: [{ productId: '123', quantity: 1 }] })
expect(response.status).toBe(201)
})Correct (extracted utilities):
// test-utils/api.ts
export function createApiClient(token?: string) {
const client = {
async post<T>(path: string, body: unknown): Promise<ApiResponse<T>> {
const req = request(app)
.post(path)
.set('Content-Type', 'application/json')
if (token) {
req.set('Authorization', `Bearer ${token}`)
}
return req.send(body)
},
// get, put, delete...
}
return client
}
export async function authenticatedClient() {
const token = await getTestToken()
return createApiClient(token)
}
// user.test.ts
test('creates user', async () => {
const api = await authenticatedClient()
const response = await api.post('/api/users', {
email: 'test@example.com',
name: 'Test'
})
expect(response.status).toBe(201)
})
// order.test.ts
test('creates order', async () => {
const api = await authenticatedClient()
const response = await api.post('/api/orders', {
items: [{ productId: '123', quantity: 1 }]
})
expect(response.status).toBe(201)
})Common test utilities:
- API client wrappers
- Authentication helpers
- Database seeding functions
- Factory functions
- Custom matchers
- Wait/polling utilities
Reference: Jest Manual - Setup Files
Eliminate Network Calls in Unit Tests
Unit tests should never make real network requests. Network calls are slow, unreliable, and create dependencies on external systems.
Incorrect (real network calls):
test('fetches user profile', async () => {
// Real HTTP request - slow, flaky, requires running server
const response = await fetch('http://localhost:3000/api/users/123')
const user = await response.json()
expect(user.name).toBe('Alice')
})
test('sends notification', async () => {
// Real external API call - costs money, slow, can fail
const result = await notificationService.send({
to: 'test@example.com',
message: 'Hello'
})
expect(result.delivered).toBe(true)
})Correct (mocked network):
test('fetches user profile', async () => {
// Mock the fetch function
const mockUser = { id: '123', name: 'Alice', email: 'alice@test.com' }
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => mockUser
})
const user = await userService.getProfile('123')
expect(user.name).toBe('Alice')
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/users/123')
)
})
test('sends notification', async () => {
const mockNotificationApi = {
send: jest.fn().mockResolvedValue({ delivered: true, messageId: 'm1' })
}
const service = new NotificationService(mockNotificationApi)
const result = await service.send({
to: 'test@example.com',
message: 'Hello'
})
expect(result.delivered).toBe(true)
expect(mockNotificationApi.send).toHaveBeenCalledWith({
to: 'test@example.com',
message: 'Hello'
})
})Mocking strategies:
- Jest mock functions:
jest.fn(),jest.spyOn() - Mock service worker (MSW) for HTTP interception
- Dependency injection with fake implementations
- Environment-based mock configuration
Reference: MSW - Mock Service Worker
Avoid Arbitrary Sleep Calls
Never use fixed delays to wait for async operations. Use explicit waits for specific conditions instead - they're faster and more reliable.
Incorrect (arbitrary sleep):
test('updates UI after data loads', async () => {
render(<UserProfile userId="123" />)
// Wait "long enough" for data to load
await sleep(2000)
expect(screen.getByText('Alice')).toBeInTheDocument()
})
// Slow (always waits 2s) and flaky (might not be enough)
test('processes background job', async () => {
await jobQueue.enqueue({ type: 'send-email', to: 'user@test.com' })
// Hope the job completes in 5 seconds
await sleep(5000)
expect(await getEmailCount('user@test.com')).toBe(1)
})
// Wastes 5s even if job completes in 100msCorrect (explicit conditions):
test('updates UI after data loads', async () => {
render(<UserProfile userId="123" />)
// Wait for specific element to appear
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
})
})
// Fast: returns as soon as condition is met
test('processes background job', async () => {
await jobQueue.enqueue({ type: 'send-email', to: 'user@test.com' })
// Poll for completion with timeout
await waitFor(
async () => {
const count = await getEmailCount('user@test.com')
expect(count).toBe(1)
},
{ timeout: 5000, interval: 100 }
)
})
// Returns immediately when done, fails fast if broken
// For event-based systems
test('receives message after publish', async () => {
const messagePromise = new Promise(resolve => {
subscriber.once('message', resolve)
})
publisher.publish({ data: 'test' })
const message = await messagePromise
expect(message.data).toBe('test')
})Async waiting strategies:
waitFor()with condition check- Promise-based event listeners
- Polling with exponential backoff
- Test framework's built-in async utilities
Reference: Testing Library - Async Utilities
Keep Unit Tests Under 100ms
Individual unit tests should complete in milliseconds. Slow tests discourage frequent execution and break the TDD rhythm.
Incorrect (slow unit test):
test('validates user data', async () => {
// Real database connection - 50-200ms
const db = await connectToDatabase()
await db.seed(testData)
// Real API call - 100-500ms
const validationResult = await externalValidationService.validate(userData)
// File system operations - 10-50ms
await writeValidationReport(validationResult)
expect(validationResult.isValid).toBe(true)
})
// Total: 160-750ms per test
// 100 tests = 16-75 secondsCorrect (fast unit test):
test('validates user data format', () => {
// In-memory, no I/O
const userData = createUser({ email: 'valid@example.com', age: 25 })
const result = validateUserData(userData)
expect(result.isValid).toBe(true)
})
// Total: <5ms
test('returns errors for invalid email', () => {
const userData = createUser({ email: 'invalid' })
const result = validateUserData(userData)
expect(result.errors).toContain('Invalid email format')
})
// Total: <5ms
// 100 tests = <500msTechniques for fast tests:
- Mock external dependencies
- Use in-memory implementations
- Avoid file I/O in unit tests
- Lazy-load expensive resources
- Parallelize independent tests
Benchmarks:
- Single unit test: <100ms
- Unit test suite: <10 seconds
- Full test run: <5 minutes
Reference: The Practical Test Pyramid - Martin Fowler
Fix Flaky Tests Immediately
A flaky test is one that sometimes passes and sometimes fails without code changes. Fix or quarantine flaky tests immediately - they erode trust in the entire suite.
Incorrect (ignoring flaky tests):
test('processes concurrent requests', async () => {
// Race condition - passes 90% of the time
const results = await Promise.all([
service.process(request1),
service.process(request2)
])
// Sometimes fails due to timing
expect(results[0].completedBefore(results[1])).toBe(true)
})
// Team learns to just re-run failed builds
// Eventually ignores all test failures
// Real bugs slip throughCorrect (fix the root cause):
test('processes requests in order received', async () => {
// Control the timing explicitly
const processOrder: string[] = []
const mockProcessor = {
process: jest.fn().mockImplementation(async (req) => {
processOrder.push(req.id)
return { id: req.id, timestamp: Date.now() }
})
}
const service = new RequestService(mockProcessor)
await service.processInOrder([
{ id: 'req-1' },
{ id: 'req-2' }
])
// Assert on the controlled behavior
expect(processOrder).toEqual(['req-1', 'req-2'])
})
// Alternative: fix the async timing issue
test('handles concurrent requests', async () => {
const startTime = Date.now()
const results = await Promise.all([
service.process(request1),
service.process(request2)
])
// Assert on stable properties, not timing
expect(results).toHaveLength(2)
expect(results.every(r => r.status === 'completed')).toBe(true)
})Flaky test triage: 1. Identify: Track flaky test frequency 2. Quarantine: Move to separate suite if can't fix immediately 3. Fix: Address root cause (timing, shared state, external deps) 4. Prevent: Add monitoring for new flaky tests
Common causes:
- Race conditions and timing dependencies
- Shared mutable state
- External service dependencies
- Non-deterministic data (time, random)
Reference: Flaky Tests Mitigation - Semaphore
Parallelize Independent Tests
Run independent tests in parallel to reduce total suite execution time. Design tests to be isolation-safe for parallel execution.
Incorrect (sequential execution):
// jest.config.js
module.exports = {
maxWorkers: 1 // Forces sequential execution
}
// Tests share database state
describe('UserService', () => {
beforeAll(async () => {
await db.users.deleteMany({}) // Clears ALL users
})
test('creates user', async () => {
await userService.create({ id: 'user-1', name: 'Alice' })
const count = await db.users.count()
expect(count).toBe(1) // Assumes no other tests created users
})
})
// Other test file accessing same table
describe('OrderService', () => {
test('associates order with user', async () => {
// Fails if UserService tests haven't run yet
const order = await orderService.create({ userId: 'user-1' })
expect(order.userId).toBe('user-1')
})
})Correct (parallel-safe tests):
// jest.config.js
module.exports = {
maxWorkers: '50%' // Use half of available CPUs
}
// Tests use unique identifiers
describe('UserService', () => {
test('creates user', async () => {
const userId = `user-${Date.now()}-${Math.random()}`
await userService.create({ id: userId, name: 'Alice' })
const user = await db.users.findById(userId)
expect(user).toBeDefined()
})
})
// Each test is independent
describe('OrderService', () => {
test('associates order with user', async () => {
// Creates its own test data
const user = await createTestUser()
const order = await orderService.create({ userId: user.id })
expect(order.userId).toBe(user.id)
})
})Parallel-safety checklist:
- No shared mutable state between tests
- Unique IDs for all test entities
- No assumptions about test execution order
- Database transactions or isolated test databases
- Mock external services per-test
Configuration:
- Jest:
maxWorkersin config - Vitest:
threadsoption - pytest:
pytest-xdistplugin
Reference: Jest Configuration - maxWorkers
Set Meaningful Coverage Targets
Aim for high coverage on critical paths, not 100% everywhere. Coverage is a guide, not a goal - focus on meaningful tests over hitting numbers.
Incorrect (coverage as goal):
// Chasing 100% coverage
test('getter returns value', () => {
const user = new User({ name: 'Alice' })
expect(user.getName()).toBe('Alice') // Tests trivial getter
})
test('setter sets value', () => {
const user = new User({ name: 'Alice' })
user.setName('Bob')
expect(user.getName()).toBe('Bob') // Tests trivial setter
})
test('toString returns string', () => {
const user = new User({ name: 'Alice' })
expect(typeof user.toString()).toBe('string') // Meaningless test
})
// Result: 100% coverage, but critical business logic untested
// Tests don't prevent bugs, just satisfy metricCorrect (strategic coverage):
// High coverage on critical business logic
describe('PaymentProcessor', () => {
it('calculates tax correctly for each region', () => { /* ... */ })
it('applies discounts in correct order', () => { /* ... */ })
it('handles currency conversion', () => { /* ... */ })
it('prevents double-charging', () => { /* ... */ })
it('validates card details', () => { /* ... */ })
})
// 95% coverage on critical module
// Lower coverage acceptable on utilities
describe('StringUtils', () => {
it('capitalizes first letter', () => { /* ... */ })
// Don't test every edge case of simple utility
})
// 60% coverage acceptable on simple utilitiesCoverage strategy:
| Module Type | Target | Rationale |
|---|---|---|
| Business logic | 90%+ | Critical, complex |
| API handlers | 80%+ | User-facing |
| Utilities | 60%+ | Simple, stable |
| Generated code | 0% | Tested elsewhere |
Better metrics:
- Mutation score (test effectiveness)
- Bug escape rate (tests vs. production bugs)
- Mean time to detect (how quickly tests catch bugs)
Reference: Code Coverage Best Practices - Google Testing Blog
Limit E2E Tests to Critical User Paths
End-to-end tests are expensive to maintain and slow to run. Reserve them for critical user journeys that generate revenue or core functionality.
Incorrect (E2E for everything):
// E2E tests for every feature
describe('User Settings', () => {
it('changes theme to dark mode', async () => { /* 30s test */ })
it('changes theme to light mode', async () => { /* 30s test */ })
it('updates avatar', async () => { /* 30s test */ })
it('changes language to Spanish', async () => { /* 30s test */ })
it('changes language to French', async () => { /* 30s test */ })
// ... 50 more settings tests
})
// E2E for edge cases
describe('Error handling', () => {
it('shows error on network timeout', async () => { /* 30s test */ })
it('shows error on invalid input', async () => { /* 30s test */ })
// ... 30 more error tests
})
// Total: 100 E2E tests, 50 minutes runtimeCorrect (E2E for critical paths only):
// E2E for core revenue-generating flows
describe('Critical User Journeys', () => {
it('completes signup to first purchase', async () => {
await page.goto('/signup')
await page.fill('[name=email]', 'new@customer.com')
await page.fill('[name=password]', 'SecurePass123!')
await page.click('button[type=submit]')
await page.waitForURL('/dashboard')
await page.click('[data-testid=browse-products]')
await page.click('[data-testid=product-1]')
await page.click('[data-testid=add-to-cart]')
await page.click('[data-testid=checkout]')
// ... complete purchase
await expect(page.locator('.order-confirmation')).toBeVisible()
})
it('existing user can login and reorder', async () => { /* ... */ })
it('user can contact support', async () => { /* ... */ })
})
// 3-5 critical E2E tests, 5 minutes runtime
// Settings, error handling tested at unit/integration levelE2E test candidates:
- Signup/onboarding flow
- Purchase/checkout
- Core feature happy path
- Authentication flows
- Critical admin operations
Test at lower levels:
- Input validation (unit)
- Error messages (unit)
- API edge cases (integration)
- Settings variations (integration)
Reference: Testing Pyramid - Mike Cohn
Test Integration at Service Boundaries
Integration tests should verify contracts between components - API shapes, database schemas, and service interfaces. Test the boundaries, not the internals.
Incorrect (testing implementation through integration):
test('user creation flow', async () => {
// Testing implementation details through integration test
const result = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Alice' })
// Checking database internals
const dbRecord = await db.query('SELECT * FROM users WHERE email = $1', ['test@example.com'])
expect(dbRecord.rows[0].password_hash).toMatch(/^\$2b\$/) // bcrypt format
expect(dbRecord.rows[0].created_at).toBeDefined()
// Checking email service internals
const sentEmails = mockEmailService.getSentEmails()
expect(sentEmails[0].template).toBe('welcome-v2')
expect(sentEmails[0].templateVars.activationLink).toMatch(/\/activate\//)
})Correct (testing boundary contracts):
describe('POST /api/users', () => {
it('returns created user with id', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Alice' })
// Test API contract, not implementation
expect(response.status).toBe(201)
expect(response.body).toMatchObject({
id: expect.any(String),
email: 'test@example.com',
name: 'Alice'
})
})
it('returns 400 for invalid email', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid', name: 'Alice' })
expect(response.status).toBe(400)
expect(response.body.error).toBeDefined()
})
it('returns 409 for duplicate email', async () => {
await createUser({ email: 'existing@example.com' })
const response = await request(app)
.post('/api/users')
.send({ email: 'existing@example.com', name: 'Bob' })
expect(response.status).toBe(409)
})
})Integration test focus:
- HTTP status codes and response shapes
- Database constraints and relationships
- Queue message formats
- External API contracts
- Error handling at boundaries
Reference: Contract Testing - Pact
Use Mutation Testing to Validate Test Quality
Mutation testing introduces small bugs (mutants) into code and checks if tests catch them. A high mutation score indicates your tests actually verify behavior.
Incorrect (high coverage, low mutation score):
// Implementation
function calculateDiscount(price: number, isPremium: boolean): number {
if (isPremium) {
return price * 0.2
}
return price * 0.1
}
// Test with 100% line coverage but poor assertions
test('calculates discount', () => {
const result = calculateDiscount(100, true)
expect(result).toBeDefined() // Passes even if logic is wrong
})
// Mutation testing creates mutants like:
// - return price * 0.3 (change constant)
// - return price * 0.1 (remove premium branch)
// - return price / 0.2 (change operator)
// All mutants SURVIVE because assertion is too weakCorrect (high mutation score):
describe('calculateDiscount', () => {
it('applies 20% discount for premium customers', () => {
const result = calculateDiscount(100, true)
expect(result).toBe(20) // Specific value kills mutants
})
it('applies 10% discount for regular customers', () => {
const result = calculateDiscount(100, false)
expect(result).toBe(10)
})
it('scales discount with price', () => {
expect(calculateDiscount(200, true)).toBe(40)
expect(calculateDiscount(50, false)).toBe(5)
})
})
// All mutants are KILLED by specific assertionsInterpreting scores:
- 90%+: Excellent test suite quality
- 70-89%: Good, review surviving mutants
- <70%: Tests need strengthening
Tools:
- JavaScript/TypeScript: Stryker Mutator
- Java: PIT
- Python: mutmut
- Go: gremlins
Note: Run mutation testing periodically, not on every commit (it's slow).
Reference: Mutation Testing - Codecov
Follow the Test Pyramid
Distribute tests according to the pyramid: many unit tests at the base, fewer integration tests in the middle, and minimal E2E tests at the top.
Incorrect (inverted pyramid):
┌─────────────────────────────┐
│ E2E Tests (500) │ ← Slow, expensive, flaky
├─────────────────────────────┤
│ Integration Tests (100) │
├─────────────────────────────┤
│ Unit Tests (50) │ ← Fast, cheap, stable
└─────────────────────────────┘
Test run time: 45 minutes
Maintenance cost: HIGH
Flakiness: FREQUENT
Infrastructure cost: $10,000/monthCorrect (proper pyramid):
┌───────────┐
│ E2E (20) │ ← Critical user journeys only
┌───┴───────────┴───┐
│ Integration (100) │ ← Component boundaries
┌───┴───────────────────┴───┐
│ Unit Tests (500) │ ← Business logic
└───────────────────────────┘
Test run time: 5 minutes
Maintenance cost: LOW
Flakiness: RARE
Infrastructure cost: $100/monthDistribution guidelines:
| Layer | Count | Scope | Speed |
|---|---|---|---|
| Unit | 70-80% | Single function/class | <100ms |
| Integration | 15-25% | Multiple components | <5s |
| E2E | 5-10% | Full user journey | <30s |
What to test at each level:
- Unit: Business logic, calculations, transformations
- Integration: API contracts, database queries, service interactions
- E2E: Critical user paths, smoke tests, happy paths
Reference: The Practical Test Pyramid - Martin Fowler
Related skills
How it compares
Pick tdd over generic testing skills when you need enforced vertical-slice red-green-refactor discipline and behavior-focused integration tests, not one-shot test file generation.
FAQ
What is the tdd skill red-green-refactor workflow?
The tdd skill runs vertical tracer bullets: write one failing test (RED), add minimal code to pass (GREEN), then refactor after green across planning, tracer-bullet, incremental loop, and refactor phases. Each cycle uses a 5-item checklist so tests stay on public behavior.
Why does tdd reject writing all tests before implementation?
The tdd skill labels horizontal slicing—bulk tests then bulk code—as an anti-pattern because tests written upfront target imagined behavior and implementation shape. Vertical slices respond to code just written, producing integration tests that survive refactors.
What reference guides ship with the tdd skill?
The tdd skill ships 5 reference markdown guides: tests.md, mocking.md, interface-design.md, deep-modules.md, and refactoring.md. They cover integration-style examples, system-boundary mocking rules, testable interfaces, module depth, and post-green refactor candidates.