Advanced Testing with TDD in React and TypeScript
The TDD cycle: Red, Green, Refactor - The foundation of test-driven development
Introduction
Test-Driven Development (TDD) is not just about writing tests, it's a methodology that fundamentally changes how we develop software. In React applications with TypeScript, TDD allows us to create robust, maintainable components with fewer bugs.
Why TDD? In my experience, projects without tests accumulate technical debt quickly. A small change can break critical functionality without you noticing until it's in production. With TDD, every line of code is backed by tests that act as a safety net.
The TDD Cycle: Red-Green-Refactor
1. Red - Write a failing test
First, write the test for functionality that doesn't exist yet. The test must fail because the implementation doesn't exist.
2. Green - Implement minimum code
Write only the necessary code to make the test pass. Don't worry about elegance, just make it work.
3. Refactor - Improve the code
Now that the test passes, improve the code while keeping the tests green. Remove duplication, improve names, optimize.
Testing pyramid: Majority of unit tests, some integration tests, few E2E
Environment Setup
Install dependencies
npm install -D vitest @testing-library/react @testing-library/jest-dom
npm install -D @testing-library/user-event @vitest/ui
npm install -D happy-dom jsdom
npm install -D @playwright/test
Configure Vitest
// 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',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.config.ts',
'**/*.d.ts'
]
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
Setup file
// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
Unit Tests with Vitest
Example 1: Pure functions
Test first:
// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice, validateEmail } from './utils'
describe('formatPrice', () => {
it('should format price in USD', () => {
expect(formatPrice(1234.56, 'USD')).toBe('$1,234.56')
})
it('should format price in EUR', () => {
expect(formatPrice(1234.56, 'EUR')).toBe('€1,234.56')
})
it('should handle zero', () => {
expect(formatPrice(0, 'USD')).toBe('$0.00')
})
it('should handle negative values', () => {
expect(formatPrice(-100, 'USD')).toBe('-$100.00')
})
})
describe('validateEmail', () => {
it('should validate correct email', () => {
expect(validateEmail('test@example.com')).toBe(true)
})
it('should reject invalid email', () => {
expect(validateEmail('invalid')).toBe(false)
expect(validateEmail('test@')).toBe(false)
expect(validateEmail('@example.com')).toBe(false)
})
it('should handle empty string', () => {
expect(validateEmail('')).toBe(false)
})
})
Implementation:
// src/lib/utils.ts
export function formatPrice(amount: number, currency: string): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
})
return formatter.format(amount)
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
Example 2: Custom hooks
Test first:
// src/hooks/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it('should increment counter', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it('should decrement counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
it('should reset counter', () => {
const { result } = renderHook(() => useCounter(10))
act(() => {
result.current.increment()
result.current.increment()
})
expect(result.current.count).toBe(12)
act(() => {
result.current.reset()
})
expect(result.current.count).toBe(10)
})
it('should not go below minimum value', () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(0)
})
})
Implementation:
// src/hooks/useCounter.ts
import { useState, useCallback } from 'react'
interface UseCounterOptions {
min?: number
max?: number
}
export function useCounter(
initialValue: number = 0,
options: UseCounterOptions = {}
) {
const [count, setCount] = useState(initialValue)
const { min, max } = options
const increment = useCallback(() => {
setCount((prev) => {
const next = prev + 1
if (max !== undefined && next > max) return prev
return next
})
}, [max])
const decrement = useCallback(() => {
setCount((prev) => {
const next = prev - 1
if (min !== undefined && next < min) return prev
return next
})
}, [min])
const reset = useCallback(() => {
setCount(initialValue)
}, [initialValue])
return { count, increment, decrement, reset }
}
Component Tests with Testing Library
Example: Form component
Test first:
// src/components/LoginForm.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('should render form fields', () => {
render(<LoginForm onSubmit={vi.fn()} />)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
})
it('should show validation errors for empty fields', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
const submitButton = screen.getByRole('button', { name: /login/i })
await user.click(submitButton)
expect(await screen.findByText(/email is required/i)).toBeInTheDocument()
expect(await screen.findByText(/password is required/i)).toBeInTheDocument()
})
it('should show error for invalid email', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
const emailInput = screen.getByLabelText(/email/i)
await user.type(emailInput, 'invalid-email')
const submitButton = screen.getByRole('button', { name: /login/i })
await user.click(submitButton)
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument()
})
it('should submit form with valid data', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<LoginForm onSubmit={onSubmit} />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
await user.type(emailInput, 'test@example.com')
await user.type(passwordInput, 'password123')
const submitButton = screen.getByRole('button', { name: /login/i })
await user.click(submitButton)
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
})
it('should disable submit button while loading', () => {
render(<LoginForm onSubmit={vi.fn()} isLoading={true} />)
const submitButton = screen.getByRole('button', { name: /login/i })
expect(submitButton).toBeDisabled()
})
})
Implementation:
// src/components/LoginForm.tsx
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(6, 'Password must be at least 6 characters')
})
type LoginFormData = z.infer<typeof loginSchema>
interface LoginFormProps {
onSubmit: (data: LoginFormData) => void
isLoading?: boolean
}
export function LoginForm({ onSubmit, isLoading = false }: LoginFormProps) {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema)
})
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
{...register('email')}
className="mt-1 block w-full rounded-md border-gray-300"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
{...register('password')}
className="mt-1 block w-full rounded-md border-gray-300"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md"
>
{isLoading ? 'Loading...' : 'Login'}
</button>
</form>
)
}
Integration Tests
Example: Authentication flow
// src/features/auth/Auth.integration.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AuthProvider, useAuth } from './AuthContext'
import { LoginPage } from './LoginPage'
const mockApi = {
login: vi.fn()
}
function TestWrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
})
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
{children}
</AuthProvider>
</QueryClientProvider>
)
}
describe('Authentication Flow', () => {
beforeEach(() => {
mockApi.login.mockClear()
})
it('should complete full login flow', async () => {
const user = userEvent.setup()
mockApi.login.mockResolvedValue({
token: 'fake-token',
user: { id: '1', email: 'test@example.com' }
})
render(<LoginPage />, { wrapper: TestWrapper })
// Fill form
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
// Submit
await user.click(screen.getByRole('button', { name: /login/i }))
// Wait for success
await waitFor(() => {
expect(mockApi.login).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
})
})
// Verify redirect or success message
expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
})
it('should show error message on failed login', async () => {
const user = userEvent.setup()
mockApi.login.mockRejectedValue(new Error('Invalid credentials'))
render(<LoginPage />, { wrapper: TestWrapper })
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'wrong-password')
await user.click(screen.getByRole('button', { name: /login/i }))
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument()
})
})
E2E Tests with Playwright
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('http://localhost:3000/login')
// Fill form
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
// Submit
await page.click('button:has-text("Login")')
// Wait for navigation
await page.waitForURL('**/dashboard')
// Verify logged in state
await expect(page.locator('text=Welcome')).toBeVisible()
})
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('http://localhost:3000/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'wrong-password')
await page.click('button:has-text("Login")')
await expect(page.locator('text=Invalid credentials')).toBeVisible()
})
test('should logout successfully', async ({ page }) => {
// Login first
await page.goto('http://localhost:3000/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button:has-text("Login")')
await page.waitForURL('**/dashboard')
// Logout
await page.click('button:has-text("Logout")')
// Verify redirected to login
await page.waitForURL('**/login')
})
})
Mocking Strategies
Module mocking
// src/services/api.test.ts
import { describe, it, expect, vi } from 'vitest'
import { fetchUser } from './api'
// Mock global fetch
global.fetch = vi.fn()
describe('fetchUser', () => {
it('should fetch user data', async () => {
const mockUser = { id: '1', name: 'John Doe' }
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser
})
const user = await fetchUser('1')
expect(user).toEqual(mockUser)
expect(global.fetch).toHaveBeenCalledWith('/api/users/1')
})
it('should throw error on failed request', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: false,
status: 404
})
await expect(fetchUser('999')).rejects.toThrow('User not found')
})
})
Mock Service Worker (MSW)
// src/mocks/handlers.ts
import { rest } from 'msw'
export const handlers = [
rest.get('/api/products', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: '1', name: 'Product 1', price: 100 },
{ id: '2', name: 'Product 2', price: 200 }
])
)
}),
rest.post('/api/auth/login', (req, res, ctx) => {
const { email, password } = req.body as any
if (email === 'test@example.com' && password === 'password123') {
return res(
ctx.status(200),
ctx.json({
token: 'fake-jwt-token',
user: { id: '1', email }
})
)
}
return res(
ctx.status(401),
ctx.json({ message: 'Invalid credentials' })
)
})
]
Code coverage report: Target 80%+ in critical projects
Best Practices
1. Descriptive tests
// ❌ WRONG
it('should work', () => {})
// ✅ CORRECT
it('should show error message when email is invalid', () => {})
2. Arrange-Act-Assert
it('should add item to cart', async () => {
// Arrange
const user = userEvent.setup()
render(<ProductCard product={mockProduct} />)
// Act
await user.click(screen.getByRole('button', { name: /add to cart/i }))
// Assert
expect(screen.getByText(/added to cart/i)).toBeInTheDocument()
})
3. No brittle tests
// ❌ WRONG: Depends on implementation
expect(wrapper.find('.button').length).toBe(1)
// ✅ CORRECT: Depends on behavior
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument()
4. Independent tests
// Each test should run independently
describe('UserList', () => {
beforeEach(() => {
// Reset state before each test
})
it('test 1', () => {})
it('test 2', () => {})
})
Common Mistakes
❌ Testing implementation details
// ❌ WRONG
expect(component.state.count).toBe(5)
// ✅ CORRECT
expect(screen.getByText('Count: 5')).toBeInTheDocument()
❌ Tests that depend on execution order
// ❌ WRONG
let sharedState
it('test 1', () => {
sharedState = 'value'
})
it('test 2', () => {
expect(sharedState).toBe('value') // Brittle!
})
❌ Not cleaning up after tests
// ✅ CORRECT
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
Conclusion
TDD in React with TypeScript is not just about writing tests, it's about designing better software. The Red-Green-Refactor cycle forces us to think about the API of our components before implementing them, resulting in more modular and maintainable code.
Key points:
- Write the test first (Red)
- Implement the minimum necessary (Green)
- Refactor with confidence (Refactor)
- Use Testing Library for user-centric tests
- Maintain high coverage but quality tests
- Mock only when necessary
Additional Resources
- Testing Library Documentation
- Vitest Documentation
- Playwright Documentation
- Kent C. Dodds - Testing JavaScript
Do you practice TDD in your React projects? What challenges have you encountered? Share your experience on LinkedIn.