Standards & Reference

Test-Driven Development

This document outlines the Test-Driven Development process following the Red-Green-Refactor cycle.

Table of Contents

  1. What is TDD?
  2. The Red-Green-Refactor Cycle
  3. Project Setup
  4. TDD Workflow Examples
  5. Testing Patterns
  6. Component TDD
  7. Hook TDD
  8. Service/Utility TDD
  9. Integration with E2E Tests
  10. Best Practices
  11. Common Pitfalls

What is TDD?

Test-Driven Development is a software development approach where tests are written before the implementation code. This methodology ensures:

  • Intentional Design: Forces you to think about the API and behavior before coding
  • High Test Coverage: Every feature has corresponding tests
  • Living Documentation: Tests describe what the code should do
  • Confidence in Refactoring: Tests catch regressions immediately
  • Reduced Debugging: Bugs are caught early in small, focused increments

The Three Laws of TDD

  1. Write no production code until you have written a failing test
  2. Write only enough of a test to demonstrate a failure
  3. Write only enough production code to make the failing test pass

The Red-Green-Refactor Cycle

The TDD cycle consists of three distinct phases:

┌─────────────────────────────────────────────────────────┐
│                                                         │
│    ┌───────┐      ┌───────┐      ┌──────────┐          │
│    │  RED  │ ───▶ │ GREEN │ ───▶ │ REFACTOR │ ───┐     │
│    └───────┘      └───────┘      └──────────┘    │     │
│        ▲                                         │     │
│        └─────────────────────────────────────────┘     │
│                                                         │
└─────────────────────────────────────────────────────────┘

🔴 RED Phase - Write a Failing Test

Goal: Write a test that fails for the right reason

  1. Think about the next small piece of functionality needed
  2. Write a test that describes the expected behavior
  3. Run the test and watch it fail
  4. Verify the failure message makes sense
// Example: Testing a new utility function
describe('formatCurrency', () => {
  it('formats a number as USD currency', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56');
  });
});

// Run: npm test
// Result: ❌ ReferenceError: formatCurrency is not defined

Key Points:

  • The test should fail because the feature doesn't exist yet
  • If it passes immediately, either the test is wrong or the feature already exists
  • Keep tests small and focused on one behavior

🟢 GREEN Phase - Make the Test Pass

Goal: Write the minimum code necessary to pass the test

  1. Implement just enough code to make the test pass
  2. Don't worry about elegance or optimization
  3. Take shortcuts if needed (hardcoding is acceptable temporarily)
  4. Run the test and verify it passes
// Minimum implementation to pass
export function formatCurrency(amount: number): string {
  return '$1,234.56'; // Hardcoded - we'll fix this in refactor
}

// Run: npm test
// Result: ✅ Test passed

Key Points:

  • Resist the urge to write more code than needed
  • "Fake it till you make it" is valid in this phase
  • The goal is to get to green as quickly as possible

🔵 REFACTOR Phase - Improve the Code

Goal: Clean up the implementation while keeping tests green

  1. Remove duplication
  2. Improve naming and readability
  3. Extract functions/components if needed
  4. Optimize performance if necessary
  5. Run tests after each change to ensure they still pass
// Refactored implementation
export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(amount);
}

// Run: npm test
// Result: ✅ Test still passes

Key Points:

  • Never refactor on red - always start from green
  • Make small, incremental changes
  • Run tests frequently during refactoring
  • This is where code quality improves

Project Setup

Install Testing Dependencies

# Unit testing with Vitest (recommended for Vite projects)
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

# Optional: Coverage reporting
npm install -D @vitest/coverage-v8

Configure Vitest

Create vitest.config.ts:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/index.ts',
      ],
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Create Test Setup File

Create src/test/setup.ts:

import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
});

Add npm Scripts

Update package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui",
    "test:watch": "vitest --watch"
  }
}

Directory Structure

src/
├── components/
│   ├── EventCard/
│   │   ├── EventCard.tsx
│   │   ├── EventCard.test.tsx    # Component tests
│   │   └── index.ts
│   └── ui/
│       └── button.tsx
├── hooks/
│   ├── useEvents.ts
│   └── useEvents.test.ts         # Hook tests
├── lib/
│   ├── utils.ts
│   └── utils.test.ts             # Utility tests
├── services/
│   ├── eventService.ts
│   └── eventService.test.ts      # Service tests
└── test/
    ├── setup.ts
    ├── mocks/                    # Mock data and handlers
    │   ├── handlers.ts
    │   └── data.ts
    └── utils.tsx                 # Test utilities

TDD Workflow Examples

Example 1: Building a Utility Function

Feature: Create a function that calculates event progress percentage

🔴 RED - Write the failing test

// src/lib/eventUtils.test.ts
import { describe, it, expect } from 'vitest';
import { calculateProgress } from './eventUtils';

describe('calculateProgress', () => {
  it('returns 0 when no deliverables exist', () => {
    expect(calculateProgress(0, 0)).toBe(0);
  });
});
npm test
# ❌ FAIL: calculateProgress is not defined

🟢 GREEN - Minimal implementation

// src/lib/eventUtils.ts
export function calculateProgress(completed: number, total: number): number {
  return 0;
}
npm test
# ✅ PASS

🔴 RED - Add next test case

// src/lib/eventUtils.test.ts
describe('calculateProgress', () => {
  it('returns 0 when no deliverables exist', () => {
    expect(calculateProgress(0, 0)).toBe(0);
  });

  it('calculates percentage correctly', () => {
    expect(calculateProgress(5, 10)).toBe(50);
  });
});
npm test
# ❌ FAIL: expected 50, received 0

🟢 GREEN - Update implementation

// src/lib/eventUtils.ts
export function calculateProgress(completed: number, total: number): number {
  if (total === 0) return 0;
  return (completed / total) * 100;
}
npm test
# ✅ PASS

🔴 RED - Add edge case

describe('calculateProgress', () => {
  // ... previous tests

  it('rounds to nearest integer', () => {
    expect(calculateProgress(1, 3)).toBe(33);
  });

  it('caps at 100 percent', () => {
    expect(calculateProgress(15, 10)).toBe(100);
  });
});

🟢 GREEN - Handle edge cases

export function calculateProgress(completed: number, total: number): number {
  if (total === 0) return 0;
  const percentage = (completed / total) * 100;
  return Math.min(Math.round(percentage), 100);
}

🔵 REFACTOR - Clean up (if needed)

// Final version with documentation
/**
 * Calculates the completion percentage of deliverables
 * @param completed - Number of completed deliverables
 * @param total - Total number of deliverables
 * @returns Percentage (0-100) rounded to nearest integer
 */
export function calculateProgress(completed: number, total: number): number {
  if (total === 0) return 0;

  const percentage = (completed / total) * 100;
  return Math.min(Math.round(percentage), 100);
}

Example 2: Building a React Component

Feature: Create a StatusBadge component

🔴 RED - Write the failing test

// src/components/StatusBadge/StatusBadge.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { StatusBadge } from './StatusBadge';

describe('StatusBadge', () => {
  it('renders the status text', () => {
    render(<StatusBadge status="on-track" />);
    expect(screen.getByText('On Track')).toBeInTheDocument();
  });
});
npm test
# ❌ FAIL: Cannot find module './StatusBadge'

🟢 GREEN - Minimal implementation

// src/components/StatusBadge/StatusBadge.tsx
export function StatusBadge({ status }: { status: string }) {
  return <span>On Track</span>;
}
npm test
# ✅ PASS

🔴 RED - Test different statuses

describe('StatusBadge', () => {
  it('renders the status text', () => {
    render(<StatusBadge status="on-track" />);
    expect(screen.getByText('On Track')).toBeInTheDocument();
  });

  it('renders at-risk status', () => {
    render(<StatusBadge status="at-risk" />);
    expect(screen.getByText('At Risk')).toBeInTheDocument();
  });

  it('renders delayed status', () => {
    render(<StatusBadge status="delayed" />);
    expect(screen.getByText('Delayed')).toBeInTheDocument();
  });
});

🟢 GREEN - Handle all statuses

type Status = 'on-track' | 'at-risk' | 'delayed';

const statusLabels: Record<Status, string> = {
  'on-track': 'On Track',
  'at-risk': 'At Risk',
  'delayed': 'Delayed',
};

export function StatusBadge({ status }: { status: Status }) {
  return <span>{statusLabels[status]}</span>;
}

🔴 RED - Test styling

describe('StatusBadge', () => {
  // ... previous tests

  it('applies success styling for on-track', () => {
    render(<StatusBadge status="on-track" />);
    const badge = screen.getByText('On Track');
    expect(badge).toHaveClass('bg-green-100');
  });

  it('applies warning styling for at-risk', () => {
    render(<StatusBadge status="at-risk" />);
    const badge = screen.getByText('At Risk');
    expect(badge).toHaveClass('bg-yellow-100');
  });

  it('applies error styling for delayed', () => {
    render(<StatusBadge status="delayed" />);
    const badge = screen.getByText('Delayed');
    expect(badge).toHaveClass('bg-red-100');
  });
});

🟢 GREEN - Add styling

type Status = 'on-track' | 'at-risk' | 'delayed';

const statusConfig: Record<Status, { label: string; className: string }> = {
  'on-track': {
    label: 'On Track',
    className: 'bg-green-100 text-green-800',
  },
  'at-risk': {
    label: 'At Risk',
    className: 'bg-yellow-100 text-yellow-800',
  },
  'delayed': {
    label: 'Delayed',
    className: 'bg-red-100 text-red-800',
  },
};

export function StatusBadge({ status }: { status: Status }) {
  const config = statusConfig[status];
  return (
    <span className={`px-2 py-1 rounded-full text-sm font-medium ${config.className}`}>
      {config.label}
    </span>
  );
}

🔵 REFACTOR - Extract types and improve

// src/components/StatusBadge/StatusBadge.tsx
import { cn } from '@/lib/utils';

export type EventStatus = 'on-track' | 'at-risk' | 'delayed';

interface StatusBadgeProps {
  status: EventStatus;
  className?: string;
}

const statusConfig: Record<EventStatus, { label: string; className: string }> = {
  'on-track': {
    label: 'On Track',
    className: 'bg-green-100 text-green-800 border-green-200',
  },
  'at-risk': {
    label: 'At Risk',
    className: 'bg-yellow-100 text-yellow-800 border-yellow-200',
  },
  'delayed': {
    label: 'Delayed',
    className: 'bg-red-100 text-red-800 border-red-200',
  },
};

export function StatusBadge({ status, className }: StatusBadgeProps) {
  const config = statusConfig[status];

  return (
    <span
      className={cn(
        'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
        config.className,
        className
      )}
    >
      {config.label}
    </span>
  );
}

Example 3: Building a Custom Hook

Feature: Create a useFilter hook for event filtering

🔴 RED - Write the failing test

// src/hooks/useFilter.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useFilter } from './useFilter';

describe('useFilter', () => {
  it('returns all items when filter is "all"', () => {
    const items = [
      { id: 1, status: 'on-track' },
      { id: 2, status: 'at-risk' },
      { id: 3, status: 'delayed' },
    ];

    const { result } = renderHook(() => useFilter(items, 'status'));

    expect(result.current.filteredItems).toEqual(items);
    expect(result.current.activeFilter).toBe('all');
  });
});

🟢 GREEN - Minimal implementation

// src/hooks/useFilter.ts
export function useFilter<T>(items: T[], filterKey: keyof T) {
  return {
    filteredItems: items,
    activeFilter: 'all',
  };
}

🔴 RED - Test filter functionality

describe('useFilter', () => {
  // ... previous test

  it('filters items by status', () => {
    const items = [
      { id: 1, status: 'on-track' },
      { id: 2, status: 'at-risk' },
      { id: 3, status: 'on-track' },
    ];

    const { result } = renderHook(() => useFilter(items, 'status'));

    act(() => {
      result.current.setFilter('on-track');
    });

    expect(result.current.filteredItems).toHaveLength(2);
    expect(result.current.activeFilter).toBe('on-track');
  });
});

🟢 GREEN - Add filter state

import { useState, useMemo } from 'react';

export function useFilter<T>(items: T[], filterKey: keyof T) {
  const [activeFilter, setActiveFilter] = useState<string>('all');

  const filteredItems = useMemo(() => {
    if (activeFilter === 'all') return items;
    return items.filter((item) => item[filterKey] === activeFilter);
  }, [items, filterKey, activeFilter]);

  return {
    filteredItems,
    activeFilter,
    setFilter: setActiveFilter,
  };
}

🔴 RED - Test filter counts

describe('useFilter', () => {
  // ... previous tests

  it('provides count for each filter option', () => {
    const items = [
      { id: 1, status: 'on-track' },
      { id: 2, status: 'at-risk' },
      { id: 3, status: 'on-track' },
      { id: 4, status: 'delayed' },
    ];

    const { result } = renderHook(() => useFilter(items, 'status'));

    expect(result.current.counts).toEqual({
      all: 4,
      'on-track': 2,
      'at-risk': 1,
      'delayed': 1,
    });
  });
});

🟢 GREEN - Add counts

import { useState, useMemo } from 'react';

export function useFilter<T>(items: T[], filterKey: keyof T) {
  const [activeFilter, setActiveFilter] = useState<string>('all');

  const filteredItems = useMemo(() => {
    if (activeFilter === 'all') return items;
    return items.filter((item) => item[filterKey] === activeFilter);
  }, [items, filterKey, activeFilter]);

  const counts = useMemo(() => {
    const result: Record<string, number> = { all: items.length };

    items.forEach((item) => {
      const value = String(item[filterKey]);
      result[value] = (result[value] || 0) + 1;
    });

    return result;
  }, [items, filterKey]);

  return {
    filteredItems,
    activeFilter,
    setFilter: setActiveFilter,
    counts,
  };
}

🔵 REFACTOR - Add types and optimize

// src/hooks/useFilter.ts
import { useState, useMemo, useCallback } from 'react';

export interface UseFilterResult<T> {
  filteredItems: T[];
  activeFilter: string;
  setFilter: (filter: string) => void;
  counts: Record<string, number>;
  resetFilter: () => void;
}

export function useFilter<T extends Record<string, unknown>>(
  items: T[],
  filterKey: keyof T
): UseFilterResult<T> {
  const [activeFilter, setActiveFilter] = useState<string>('all');

  const filteredItems = useMemo(() => {
    if (activeFilter === 'all') return items;
    return items.filter((item) => item[filterKey] === activeFilter);
  }, [items, filterKey, activeFilter]);

  const counts = useMemo(() => {
    const result: Record<string, number> = { all: items.length };

    for (const item of items) {
      const value = String(item[filterKey]);
      result[value] = (result[value] || 0) + 1;
    }

    return result;
  }, [items, filterKey]);

  const resetFilter = useCallback(() => {
    setActiveFilter('all');
  }, []);

  return {
    filteredItems,
    activeFilter,
    setFilter: setActiveFilter,
    counts,
    resetFilter,
  };
}

Testing Patterns

Arrange-Act-Assert (AAA)

Structure every test with three clear sections:

it('filters events by status', () => {
  // Arrange - Set up test data and conditions
  const events = [
    { id: 1, name: 'Event A', status: 'on-track' },
    { id: 2, name: 'Event B', status: 'at-risk' },
  ];

  // Act - Execute the code under test
  const result = filterEvents(events, 'on-track');

  // Assert - Verify the expected outcome
  expect(result).toHaveLength(1);
  expect(result[0].name).toBe('Event A');
});

Given-When-Then (BDD Style)

describe('EventCard', () => {
  describe('given an event with delayed status', () => {
    const event = createEvent({ status: 'delayed' });

    describe('when rendered', () => {
      it('then displays a red border', () => {
        render(<EventCard event={event} />);
        const card = screen.getByTestId('event-card');
        expect(card).toHaveClass('border-l-red-500');
      });

      it('then shows "Delayed" badge', () => {
        render(<EventCard event={event} />);
        expect(screen.getByText('Delayed')).toBeInTheDocument();
      });
    });
  });
});

Test Doubles

// Mock function
const mockOnClick = vi.fn();
render(<Button onClick={mockOnClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(mockOnClick).toHaveBeenCalledTimes(1);

// Mock module
vi.mock('@/services/eventService', () => ({
  fetchEvents: vi.fn().mockResolvedValue([{ id: 1, name: 'Test Event' }]),
}));

// Spy on existing function
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// ... test code
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();

Component TDD

Testing Component Rendering

describe('EventCard', () => {
  const defaultProps = {
    name: 'Test Event',
    date: 'Jan 15, 2026',
    location: 'Seattle, WA',
    status: 'on-track' as const,
    progress: 75,
  };

  it('renders event name', () => {
    render(<EventCard {...defaultProps} />);
    expect(screen.getByText('Test Event')).toBeInTheDocument();
  });

  it('renders event date with calendar icon', () => {
    render(<EventCard {...defaultProps} />);
    expect(screen.getByText('Jan 15, 2026')).toBeInTheDocument();
  });

  it('renders progress bar with correct value', () => {
    render(<EventCard {...defaultProps} />);
    const progressBar = screen.getByRole('progressbar');
    expect(progressBar).toHaveAttribute('aria-valuenow', '75');
  });
});

Testing User Interactions

describe('FilterBar', () => {
  it('calls onFilterChange when filter button is clicked', async () => {
    const handleFilterChange = vi.fn();
    const user = userEvent.setup();

    render(<FilterBar onFilterChange={handleFilterChange} />);

    await user.click(screen.getByRole('button', { name: /at risk/i }));

    expect(handleFilterChange).toHaveBeenCalledWith('at-risk');
  });

  it('highlights active filter button', async () => {
    const user = userEvent.setup();
    render(<FilterBar onFilterChange={vi.fn()} />);

    const atRiskButton = screen.getByRole('button', { name: /at risk/i });
    await user.click(atRiskButton);

    expect(atRiskButton).toHaveAttribute('data-state', 'active');
  });
});

Testing Async Components

describe('EventList', () => {
  it('shows loading state while fetching', () => {
    render(<EventList />);
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
  });

  it('renders events after loading', async () => {
    render(<EventList />);

    await waitFor(() => {
      expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
    });

    expect(screen.getByText('Event 1')).toBeInTheDocument();
    expect(screen.getByText('Event 2')).toBeInTheDocument();
  });

  it('shows error message on fetch failure', async () => {
    server.use(
      http.get('/api/events', () => {
        return HttpResponse.error();
      })
    );

    render(<EventList />);

    await waitFor(() => {
      expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
    });
  });
});

Hook TDD

Testing Custom Hooks

// src/hooks/useToggle.test.ts
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

describe('useToggle', () => {
  it('starts with initial value', () => {
    const { result } = renderHook(() => useToggle(false));
    expect(result.current[0]).toBe(false);
  });

  it('toggles value when toggle is called', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current[1](); // toggle function
    });

    expect(result.current[0]).toBe(true);
  });

  it('sets value directly', () => {
    const { result } = renderHook(() => useToggle(false));

    act(() => {
      result.current[2](true); // setValue function
    });

    expect(result.current[0]).toBe(true);
  });
});

Testing Hooks with Context

// Create a wrapper with providers
const createWrapper = () => {
  return function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthProvider>
          {children}
        </AuthProvider>
      </QueryClientProvider>
    );
  };
};

describe('useUser', () => {
  it('returns current user from context', () => {
    const { result } = renderHook(() => useUser(), {
      wrapper: createWrapper(),
    });

    expect(result.current.user).toBeDefined();
    expect(result.current.isAuthenticated).toBe(true);
  });
});

Service/Utility TDD

Testing API Services

// src/services/eventService.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { eventService } from './eventService';

const server = setupServer();

beforeEach(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('eventService', () => {
  describe('getEvents', () => {
    it('fetches events successfully', async () => {
      const mockEvents = [
        { id: '1', name: 'Event 1' },
        { id: '2', name: 'Event 2' },
      ];

      server.use(
        http.get('/api/events', () => {
          return HttpResponse.json(mockEvents);
        })
      );

      const result = await eventService.getEvents();

      expect(result).toEqual(mockEvents);
    });

    it('throws error on network failure', async () => {
      server.use(
        http.get('/api/events', () => {
          return HttpResponse.error();
        })
      );

      await expect(eventService.getEvents()).rejects.toThrow();
    });
  });

  describe('createEvent', () => {
    it('creates event and returns with id', async () => {
      const newEvent = { name: 'New Event', date: '2026-01-15' };

      server.use(
        http.post('/api/events', async ({ request }) => {
          const body = await request.json();
          return HttpResponse.json({ id: '123', ...body }, { status: 201 });
        })
      );

      const result = await eventService.createEvent(newEvent);

      expect(result.id).toBe('123');
      expect(result.name).toBe('New Event');
    });
  });
});

Testing Utility Functions

// src/lib/dateUtils.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, isOverdue, getDaysUntil } from './dateUtils';

describe('dateUtils', () => {
  describe('formatDate', () => {
    it('formats date in readable format', () => {
      expect(formatDate('2026-01-15')).toBe('Jan 15, 2026');
    });

    it('handles date objects', () => {
      expect(formatDate(new Date(2026, 0, 15))).toBe('Jan 15, 2026');
    });

    it('returns empty string for invalid date', () => {
      expect(formatDate('invalid')).toBe('');
    });
  });

  describe('isOverdue', () => {
    it('returns true for past dates', () => {
      expect(isOverdue('2020-01-01')).toBe(true);
    });

    it('returns false for future dates', () => {
      expect(isOverdue('2030-01-01')).toBe(false);
    });
  });

  describe('getDaysUntil', () => {
    it('calculates days until future date', () => {
      const futureDate = new Date();
      futureDate.setDate(futureDate.getDate() + 5);
      expect(getDaysUntil(futureDate)).toBe(5);
    });

    it('returns negative for past dates', () => {
      const pastDate = new Date();
      pastDate.setDate(pastDate.getDate() - 3);
      expect(getDaysUntil(pastDate)).toBe(-3);
    });
  });
});

Integration with E2E Tests

Test Pyramid

                    ┌───────────┐
                   /   E2E      \        Few, slow, high confidence
                  /   Tests      \
                 ├───────────────┤
                /   Integration   \      Some, medium speed
               /     Tests         \
              ├─────────────────────┤
             /      Unit Tests       \   Many, fast, focused
            └─────────────────────────┘

When to Use Each Test Type

Test TypeUse ForTDD Phase
UnitFunctions, utilities, isolated componentsPrimary TDD cycle
IntegrationComponent interactions, hooks with contextAfter unit tests pass
E2ECritical user journeys, full workflowsAfter feature complete

TDD Flow Across Test Types

// 1. Start with unit test
// src/lib/validation.test.ts
it('validates email format', () => {
  expect(isValidEmail('test@example.com')).toBe(true);
  expect(isValidEmail('invalid')).toBe(false);
});

// 2. Add component test
// src/components/EmailInput.test.tsx
it('shows error for invalid email', async () => {
  render(<EmailInput />);
  await userEvent.type(screen.getByRole('textbox'), 'invalid');
  expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
});

// 3. Add E2E test for full flow
// e2e/tests/registration.spec.ts
test('shows validation error for invalid email', async ({ page }) => {
  await page.goto('/register');
  await page.getByLabel('Email').fill('invalid');
  await page.getByRole('button', { name: 'Submit' }).click();
  await expect(page.getByText(/invalid email/i)).toBeVisible();
});

Best Practices

1. Write Tests First, Always

// ❌ Bad: Writing implementation first
function calculateTotal(items) { /* ... */ }
// Then writing tests after

// ✅ Good: Test first
it('calculates total price of items', () => {
  const items = [{ price: 10 }, { price: 20 }];
  expect(calculateTotal(items)).toBe(30);
});
// Then implement to make it pass

2. Keep Tests Small and Focused

// ❌ Bad: Testing multiple behaviors
it('handles event operations', () => {
  const event = createEvent();
  expect(event.name).toBe('Test');
  event.setStatus('delayed');
  expect(event.status).toBe('delayed');
  event.addDeliverable({ name: 'Doc' });
  expect(event.deliverables).toHaveLength(1);
});

// ✅ Good: One assertion per test
it('creates event with default name', () => {
  const event = createEvent();
  expect(event.name).toBe('Test');
});

it('updates event status', () => {
  const event = createEvent();
  event.setStatus('delayed');
  expect(event.status).toBe('delayed');
});

it('adds deliverable to event', () => {
  const event = createEvent();
  event.addDeliverable({ name: 'Doc' });
  expect(event.deliverables).toHaveLength(1);
});

3. Use Descriptive Test Names

// ❌ Bad: Vague names
it('works', () => { /* ... */ });
it('handles error', () => { /* ... */ });

// ✅ Good: Describes behavior and context
it('displays error message when API returns 500', () => { /* ... */ });
it('disables submit button while form is submitting', () => { /* ... */ });
it('filters events to show only delayed items when delayed filter is active', () => { /* ... */ });

4. Test Behavior, Not Implementation

// ❌ Bad: Testing implementation details
it('calls setState with new value', () => {
  const setState = vi.fn();
  vi.spyOn(React, 'useState').mockReturnValue([false, setState]);
  // ...
  expect(setState).toHaveBeenCalledWith(true);
});

// ✅ Good: Testing observable behavior
it('shows menu when toggle button is clicked', async () => {
  render(<Navigation />);
  await userEvent.click(screen.getByRole('button', { name: /menu/i }));
  expect(screen.getByRole('navigation')).toBeVisible();
});

5. Don't Test Framework Code

// ❌ Bad: Testing React's useState
it('updates state when setValue is called', () => {
  const [value, setValue] = useState(0);
  setValue(1);
  expect(value).toBe(1); // Testing React, not your code
});

// ✅ Good: Testing your component's behavior
it('increments counter when plus button is clicked', async () => {
  render(<Counter />);
  await userEvent.click(screen.getByRole('button', { name: '+' }));
  expect(screen.getByText('1')).toBeInTheDocument();
});

6. Use Test Factories

// src/test/factories.ts
export function createEvent(overrides: Partial<Event> = {}): Event {
  return {
    id: '1',
    name: 'Test Event',
    date: '2026-01-15',
    location: 'Seattle, WA',
    status: 'on-track',
    progress: 50,
    ...overrides,
  };
}

// In tests
it('shows delayed styling for delayed events', () => {
  const event = createEvent({ status: 'delayed' });
  render(<EventCard event={event} />);
  expect(screen.getByTestId('event-card')).toHaveClass('border-red-500');
});

7. Maintain Test Independence

// ❌ Bad: Tests depend on each other
let counter = 0;

it('increments counter', () => {
  counter++;
  expect(counter).toBe(1);
});

it('counter is still incremented', () => {
  expect(counter).toBe(1); // Depends on previous test
});

// ✅ Good: Each test is independent
it('increments counter', () => {
  const counter = createCounter();
  counter.increment();
  expect(counter.value).toBe(1);
});

it('decrements counter', () => {
  const counter = createCounter();
  counter.decrement();
  expect(counter.value).toBe(-1);
});

Common Pitfalls

1. Writing Too Much Code Before Testing

Problem: Writing the entire feature then adding tests

Solution: Take tiny steps - one test, one small implementation

// Take the smallest possible step
it('returns empty array for empty input', () => {
  expect(filterEvents([])).toEqual([]);
});

2. Testing Implementation Details

Problem: Tests break when refactoring even though behavior is correct

Solution: Test the public API and observable behavior

// ❌ Tests internal state
expect(component.state.isOpen).toBe(true);

// ✅ Tests what user sees
expect(screen.getByRole('dialog')).toBeVisible();

3. Ignoring the Refactor Phase

Problem: Code works but accumulates technical debt

Solution: Always take time to refactor after green

// After tests pass, ask:
// - Can I remove duplication?
// - Are names clear?
// - Is there a simpler way?
// - Should I extract a function/component?

4. Writing Tests That Are Too Large

Problem: Tests are hard to debug when they fail

Solution: Each test should verify one specific behavior

5. Not Running Tests Frequently

Problem: Long feedback cycles make debugging harder

Solution: Run tests after every small change

# Use watch mode
npm run test:watch

6. Skipping Tests When "It's Simple"

Problem: "Simple" code often has edge cases

Solution: Everything gets tested - no exceptions


Quick Reference

TDD Cycle Checklist

□ RED
  □ Write a failing test
  □ Run it and see it fail
  □ Verify failure message makes sense

□ GREEN
  □ Write minimal code to pass
  □ Run test and see it pass
  □ Don't add extra functionality

□ REFACTOR
  □ Clean up the code
  □ Run tests after each change
  □ Commit when satisfied

Command Cheat Sheet

npm test              # Run tests in watch mode
npm run test:run      # Run tests once
npm run test:coverage # Run with coverage report
npm run test:ui       # Open Vitest UI

Test Structure Template

describe('ComponentName', () => {
  // Setup
  const defaultProps = { /* ... */ };

  beforeEach(() => {
    // Reset mocks, etc.
  });

  describe('when condition', () => {
    it('should expected behavior', () => {
      // Arrange
      // Act
      // Assert
    });
  });
});

Cross-Language Testing Gotchas

Python: Async Mock Methods

When adding new async method calls to existing code (e.g., await redis.zadd()), tests using MagicMock() will fail because MagicMock doesn't support await by default.

Problem:

# Code added: await redis_client.zadd(key, ...)
# Test uses: mock_redis = MagicMock()
# Result: TypeError: object MagicMock can't be used in 'await' expression

Solution:

from unittest.mock import MagicMock, AsyncMock

mock_redis = MagicMock()
mock_redis.zadd = AsyncMock()  # Add AsyncMock for new async methods
mock_redis.zrem = AsyncMock()

GitHub Actions & Fork PRs

GitHub Actions does not provide secrets to fork PRs for security reasons. This means:

PR TypeSecrets AvailableImpact
Same-repo PR✅ YesAll secret-dependent tests pass
Fork PR❌ NoTests requiring API keys fail with 401/403

What to do when reviewing fork PRs:

  1. Check if test failures are secret-related (look for auth errors)
  2. Focus on test failures from code changes, not missing secrets
  3. Secret-dependent tests will pass after merge to main

Additional Resources

Previous
Code Quality