Testing

Testing Avanzado con TDD en React y TypeScript

Guía completa de Test-Driven Development en aplicaciones React con TypeScript.

FC

Fernando Caravaca

FullStack Developer

20 de octubre de 2024
18 min de lectura
Diagrama del ciclo TDD

Testing Avanzado con TDD en React y TypeScript

TDD Cycle 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.

Testing Pyramid 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' })
    )
  })
]

Test Coverage Report 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:

  1. Escribe el test primero (Red)
  2. Implementa lo mínimo necesario (Green)
  3. Refactoriza con confianza (Refactor)
  4. Usa Testing Library para tests centrados en el usuario
  5. Mantén cobertura alta pero tests de calidad
  6. Mock solo cuando sea necesario

Recursos adicionales


¿Practicas TDD en tus proyectos React? ¿Qué desafíos has encontrado? Comparte tu experiencia en LinkedIn.

#React#TypeScript#TDD#Testing#Vitest

¿Te gustó este artículo?

Comparte tus pensamientos en LinkedIn o contáctame si quieres discutir estos temas.

Fernando Caravaca - FullStack Developer