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.
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 Pain | Modern Solution |
|---|---|
| Slow test runner | Vitest (native ESM, ~5Γ faster than Jest) |
| TypeScript friction | Vitest has first-class TS; no ts-jest needed |
| Complex mocking | MSW 2.x + vi.mock() covers 95% of cases |
| Writing tests first | AI 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
useCartTotalhook 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
| Scenario | Use Unit/Integration | Use 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
Recommended Thresholds for Production Apps
// 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.
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.
Affiliate disclosure: Some links below are affiliate links β we may earn a small commission at no extra cost to you. Learn more.
Recommended Tools & Resources
DigitalOcean
$200 credit for new users. Simple, affordable cloud hosting for developers.
GitHub Student Pack
Free access to 100+ developer tools. Perfect for students and new devs.
Vercel
Deploy frontend apps instantly. Free tier is generous for side projects.
DevPlaybook Products
Boilerplates, scripts & AI toolkits to 10x your dev workflow.