Testing Avanzado con TDD en React y TypeScript
El ciclo TDD: Red, Green, Refactor - La base del desarrollo guiado por tests
Introducción
El Test-Driven Development (TDD) no es solo escribir tests, es una metodología que cambia fundamentalmente cómo desarrollamos software. En aplicaciones React con TypeScript, TDD nos permite crear componentes robustos, mantenibles y con menos bugs.
¿Por qué TDD? En mi experiencia, los proyectos sin tests acumulan deuda técnica rápidamente. Un cambio pequeño puede romper funcionalidades críticas sin que te des cuenta hasta que está en producción. Con TDD, cada línea de código está respaldada por tests que actúan como red de seguridad.
El ciclo TDD: Red-Green-Refactor
1. Red - Escribe un test que falle
Primero escribes el test para la funcionalidad que aún no existe. El test debe fallar porque la implementación no existe.
2. Green - Implementa el código mínimo
Escribe solo el código necesario para que el test pase. No te preocupes por la elegancia, solo haz que funcione.
3. Refactor - Mejora el código
Ahora que el test pasa, mejora el código manteniendo los tests verdes. Elimina duplicación, mejora nombres, optimiza.
Pirámide de testing: Mayoría de tests unitarios, algunos de integración, pocos E2E
Configuración del entorno
Instalar dependencias
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
Configurar 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()
})
Tests Unitarios con Vitest
Ejemplo 1: Funciones puras
Test primero:
// 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)
})
})
Implementación:
// 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)
}
Ejemplo 2: Hooks personalizados
Test primero:
// 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)
})
})
Implementación:
// 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 }
}
Tests de Componentes con Testing Library
Ejemplo: Componente de formulario
Test primero:
// 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()
})
})
Implementación:
// 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>
)
}
Tests de Integración
Ejemplo: Flujo de autenticación
// 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()
})
})
Tests E2E con 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')
})
})
Estrategias de Mocking
Mock de módulos
// 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' })
)
})
]
Reporte de cobertura de código: Objetivo 80%+ en proyectos críticos
Mejores prácticas
1. Tests descriptivos
// ❌ MAL
it('should work', () => {})
// ✅ BIEN
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 tests frágiles
// ❌ MAL: Depende de la implementación
expect(wrapper.find('.button').length).toBe(1)
// ✅ BIEN: Depende del comportamiento
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument()
4. Tests independientes
// Cada test debe poder ejecutarse independientemente
describe('UserList', () => {
beforeEach(() => {
// Reset state antes de cada test
})
it('test 1', () => {})
it('test 2', () => {})
})
Errores comunes
❌ Testing implementation details
// ❌ MAL
expect(component.state.count).toBe(5)
// ✅ BIEN
expect(screen.getByText('Count: 5')).toBeInTheDocument()
❌ Tests que dependen de orden de ejecución
// ❌ MAL
let sharedState
it('test 1', () => {
sharedState = 'value'
})
it('test 2', () => {
expect(sharedState).toBe('value') // Frágil!
})
❌ No limpiar después de los tests
// ✅ BIEN
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
Conclusión
TDD en React con TypeScript no es solo sobre escribir tests, es sobre diseñar mejor software. El ciclo Red-Green-Refactor nos fuerza a pensar en la API de nuestros componentes antes de implementarlos, resultando en código más modular y mantenible.
Puntos clave:
- Escribe el test primero (Red)
- Implementa lo mínimo necesario (Green)
- Refactoriza con confianza (Refactor)
- Usa Testing Library para tests centrados en el usuario
- Mantén cobertura alta pero tests de calidad
- Mock solo cuando sea necesario
Recursos adicionales
- Testing Library Documentation
- Vitest Documentation
- Playwright Documentation
- Kent C. Dodds - Testing JavaScript
¿Practicas TDD en tus proyectos React? ¿Qué desafíos has encontrado? Comparte tu experiencia en LinkedIn.