Skip to main content
← All Articles Β· Β· 12 min read

Modern TDD in 2026: Test-Driven Development with Vitest, Playwright & AI Copilots

Test-Driven Development has evolved. This practical guide covers modern TDD workflows using Vitest for unit tests, Playwright for E2E, MSW for API mocking, and how AI coding assistants (GitHub Copilot, Claude Code) are changing how we write tests first.

testingTDDvitestplaywrightjavascripttypescriptai-assisted

Test-Driven Development (TDD) never died β€” it just got better tooling.

The β€œwrite tests first” discipline that Kent Beck popularized in the early 2000s is experiencing a renaissance in 2026, powered by faster test runners, better TypeScript integration, and AI coding assistants that can write test scaffolding in seconds. The result: TDD is more practical and productive than it’s ever been.

This guide covers the complete modern TDD workflow: the philosophy, the toolchain, and how AI changes the equation.


Why TDD in 2026 Is Different

The Old Friction Points Are Gone

The original complaints against TDD were largely about tooling friction:

  • Tests were slow (Jest with Babel could take 30+ seconds for cold starts)
  • TypeScript support was bolted-on and painful
  • Mocking required complex setup (sinon, proxyquire, manual module patching)
  • Writing the test before the code felt unnatural

All four of these are solved in 2026:

Old PainModern Solution
Slow test runnerVitest (native ESM, ~5Γ— faster than Jest)
TypeScript frictionVitest has first-class TS; no ts-jest needed
Complex mockingMSW 2.x + vi.mock() covers 95% of cases
Writing tests firstAI writes the initial test scaffold in 3 seconds

The Red-Green-Refactor Loop Is Now Faster Than Writing Code

The classic TDD cycle:

Red   β†’ Write a failing test
Green β†’ Write minimum code to pass
Refactor β†’ Improve code, tests stay green

With Vitest’s watch mode (vitest --watch) and sub-second hot reloading, this cycle runs in real-time as you type. The feedback loop is now fast enough to feel conversational.


Setting Up a Modern TDD Environment

Install the Stack

# Core: Vitest + Testing Library
pnpm add -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom

# E2E: Playwright
pnpm add -D @playwright/test
npx playwright install

# API Mocking: MSW 2.x
pnpm add -D msw

# Coverage
pnpm add -D @vitest/coverage-v8

Vitest Config (vitest.config.ts)

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
      },
    },
  },
})

Setup File (src/test/setup.ts)

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

// Start MSW server
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => {
  cleanup()
  server.resetHandlers()
})
afterAll(() => server.close())

The Modern TDD Workflow in Practice

Step 1: Write the Failing Test (Red)

Let’s TDD a useUserProfile hook that fetches user data:

// src/hooks/__tests__/useUserProfile.test.ts
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useUserProfile } from '../useUserProfile'
import { createWrapper } from '../../test/utils'

describe('useUserProfile', () => {
  it('returns loading state initially', () => {
    const { result } = renderHook(() => useUserProfile('user-123'), {
      wrapper: createWrapper(),
    })

    expect(result.current.isLoading).toBe(true)
    expect(result.current.user).toBeNull()
  })

  it('returns user data on success', async () => {
    const { result } = renderHook(() => useUserProfile('user-123'), {
      wrapper: createWrapper(),
    })

    await waitFor(() => expect(result.current.isLoading).toBe(false))

    expect(result.current.user).toMatchObject({
      id: 'user-123',
      name: 'Jane Doe',
      email: '[email protected]',
    })
    expect(result.current.error).toBeNull()
  })

  it('returns error state when API fails', async () => {
    // Override MSW handler for this test
    server.use(
      http.get('/api/users/:id', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    const { result } = renderHook(() => useUserProfile('user-123'), {
      wrapper: createWrapper(),
    })

    await waitFor(() => expect(result.current.isLoading).toBe(false))

    expect(result.current.error).toBe('Failed to fetch user profile')
    expect(result.current.user).toBeNull()
  })
})

Run the test: vitest β€” it fails immediately. βœ… That’s exactly right. We’re at Red.

Step 2: MSW Handler (Mock the API)

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    if (params.id === 'user-123') {
      return HttpResponse.json({
        id: 'user-123',
        name: 'Jane Doe',
        email: '[email protected]',
      })
    }
    return new HttpResponse(null, { status: 404 })
  }),
]

Step 3: Implement the Minimum Code (Green)

// src/hooks/useUserProfile.ts
import { useState, useEffect } from 'react'

interface UserProfile {
  id: string
  name: string
  email: string
}

interface UseUserProfileResult {
  user: UserProfile | null
  isLoading: boolean
  error: string | null
}

export function useUserProfile(userId: string): UseUserProfileResult {
  const [user, setUser] = useState<UserProfile | null>(null)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    let cancelled = false

    async function fetchUser() {
      try {
        setIsLoading(true)
        setError(null)

        const response = await fetch(`/api/users/${userId}`)
        if (!response.ok) throw new Error('Failed to fetch user profile')

        const data = await response.json()
        if (!cancelled) setUser(data)
      } catch (err) {
        if (!cancelled) setError(err instanceof Error ? err.message : 'Unknown error')
      } finally {
        if (!cancelled) setIsLoading(false)
      }
    }

    fetchUser()
    return () => { cancelled = true }
  }, [userId])

  return { user, isLoading, error }
}

Run the test: All 3 pass. βœ… We’re at Green.

Step 4: Refactor (and tests stay green)

Now we can safely refactor to use React Query without fear:

// src/hooks/useUserProfile.ts (refactored)
import { useQuery } from '@tanstack/react-query'

export function useUserProfile(userId: string) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`/api/users/${userId}`)
      if (!response.ok) throw new Error('Failed to fetch user profile')
      return response.json()
    },
  })

  return {
    user: user ?? null,
    isLoading,
    error: error instanceof Error ? error.message : null,
  }
}

Run the test again: Still all green. βœ… We refactored safely.


How AI Copilots Change TDD

Pattern 1: AI Writes the Test Scaffold, You Write the Assertions

The most tedious part of TDD β€” the boilerplate β€” is now AI territory.

Prompt to Claude Code / GitHub Copilot:

β€œWrite Vitest tests for a useCartTotal hook that: takes an array of {price: number, quantity: number} items, returns a total price, handles empty array, handles items with 0 quantity. Use MSW for any API calls.”

The AI generates the full test structure in seconds. You review, adjust the specific expected values, and run it to confirm it’s red.

What AI is good at: Boilerplate, edge case enumeration, test structure What you still own: The specific business rules, what β€œcorrect” means for your domain

Pattern 2: AI as a Test Reviewer

After writing your tests, paste them into Claude and ask:

β€œWhat edge cases am I missing in these tests? What would make this test suite more robust?”

Common findings AI surfaces that humans miss:

  • Race condition tests (fast sequential state updates)
  • Memory leak tests (unmount during async operations)
  • Locale/timezone-sensitive tests
  • Boundary condition tests (0, -1, MAX_INT)

Pattern 3: AI Mutation Testing (Without the Tool)

Mutation testing tools (Stryker, etc.) modify your code and check if tests catch the change. AI can simulate this mentally:

Prompt:

β€œI’ll show you my function and its tests. Tell me: if I changed > to >= on line 12, would the tests catch it?”

This is faster than running Stryker and helps identify the specific gaps worth closing.


E2E Testing with Playwright

Unit tests test logic. E2E tests test the user experience. Both are needed.

Modern Playwright Setup (2026)

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html'], ['list']],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'pnpm dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
})

TDD-Style Playwright Test

// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Checkout Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/shop')
  })

  test('user can add item to cart and checkout', async ({ page }) => {
    // 1. Add item to cart
    await page.getByRole('button', { name: 'Add to cart', exact: false }).first().click()

    // 2. Verify cart badge updates
    await expect(page.getByTestId('cart-badge')).toHaveText('1')

    // 3. Open cart
    await page.getByRole('link', { name: 'Cart' }).click()

    // 4. Proceed to checkout
    await page.getByRole('button', { name: 'Proceed to checkout' }).click()

    // 5. Verify we're on checkout page
    await expect(page).toHaveURL('/checkout')
    await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible()
  })

  test('shows empty cart message when no items', async ({ page }) => {
    await page.getByRole('link', { name: 'Cart' }).click()
    await expect(page.getByText('Your cart is empty')).toBeVisible()
  })
})

When Unit Tests vs When E2E

ScenarioUse Unit/IntegrationUse E2E
Business logic functionβœ…βŒ
React component renderingβœ…βŒ
API request/response handlingβœ… (with MSW)❌
User flows across pagesβŒβœ…
Form validation UXβœ… (Testing Library)βœ… (for critical paths)
Performance/load testingβŒβœ… (Playwright traces)
Visual regressionβŒβœ… (Playwright screenshots)

TDD Anti-Patterns to Avoid

1. Testing Implementation Details (Not Behavior)

Wrong:

// Tests the internal state variable name
expect(component.state.isDropdownOpen).toBe(true)

Right:

// Tests what the user sees
expect(screen.getByRole('listbox')).toBeVisible()

2. Snapshot Tests as the Only Tests

Snapshot tests are good for catching accidental changes, but terrible as a substitute for behavior tests. Use them sparingly β€” only for stable, well-designed components.

3. 100% Coverage as the Goal

Coverage is a floor, not a ceiling. 80% meaningful coverage beats 100% superficial coverage. Never write tests just to hit a number.

4. Testing Every Internal Function

Private helpers and internal implementation functions don’t need their own tests. Test through the public API. If internals are complex enough to need isolated tests, extract them into a testable module.


Coverage Targets and CI Integration

// vitest.config.ts
coverage: {
  thresholds: {
    lines: 80,      // Business logic must be covered
    functions: 80,  // Every exported function
    branches: 70,   // Conditionals (70% is realistic, not aspirational)
    statements: 80,
  }
}

GitHub Actions Workflow

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Unit Tests
        run: pnpm vitest run --coverage

      - name: E2E Tests
        run: pnpm playwright test

      - name: Upload Coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

TDD in 2026: What the Numbers Look Like

Teams that implement consistent TDD with this stack report:

  • Bug escape rate: 40–60% reduction in production bugs vs no testing
  • Feature velocity: Slight slowdown initially, 20–30% faster after 6 months (less time debugging)
  • Refactor confidence: 10Γ— more refactoring happens when there are good tests
  • Onboarding time: New team members understand codebase 50% faster via tests-as-documentation

Quick Reference: The Modern TDD Cheat Sheet

Setup:        Vitest + Testing Library + MSW + Playwright
Watch mode:   vitest --watch (unit) | playwright --headed (E2E debug)
Coverage:     vitest --coverage (lines β‰₯80%, branches β‰₯70%)
AI role:      Scaffold tests, enumerate edge cases, review assertions
Golden rule:  Test behavior, not implementation
CI:           Unit β†’ Integration β†’ E2E (fail fast on unit)

TDD in 2026 is less about discipline and more about tooling that makes the β€œright way” the fast way. When your tests run in milliseconds and AI handles the boilerplate, writing tests first stops feeling like overhead and starts feeling like the most efficient path from idea to working, confident code.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.