React + TypeScript
Semana 10 de 9·8h

Testing y buenas prácticas

Vitest y React Testing Library con TypeScript, mocks tipados y cobertura de código.

VitestReact Testing LibraryTypeScript
Objetivos de aprendizaje
  • Configurar Vitest en un proyecto React + TypeScript con Vite
  • Escribir tests unitarios para funciones y hooks con TypeScript
  • Testear componentes React con React Testing Library tipado
  • Aplicar el patrón vi.mock con factory sin variables externas (hoisting)
  • Usar los wrappers BrowserRouter, TemaProvider y AuthProvider según el componente

🎯 Objetivo de la semana: Al terminar sabrás configurar Vitest en el proyecto CMS, escribir tests para componentes y hooks, crear mocks tipados de servicios y aplicar los patrones de testing que el proyecto real usa.

🔑 Concepto clave: El test como documentación — un test bien escrito describe el contrato de un componente: qué recibe, qué renderiza y cómo reacciona a las interacciones del usuario, sin acoplarse a detalles de implementación interna.

🛠 Tarea práctica: Configurar Vitest en el CMS y escribir tests para ArticuloCard, el hook useFormularioArticulo y el formulario de login del panel admin.

📋 Entregable: npm run test:run ejecuta al menos 10 tests que pasan. Los tres artefactos clave del CMS tienen cobertura real.


1. Configuración de Vitest con TypeScript

Vitest es Jest reescrito para el ecosistema de ESModules — misma API (describe, it, expect) pero integrado directamente con el pipeline de Vite. Con Jest necesitarías configurar Babel o ts-jest para transformar TypeScript, y los alias de ruta como @/components/... fallarían sin configuración adicional. Con Vitest, si el proyecto ya corre con Vite, los tests corren con la misma configuración sin trabajo extra.

La instalación mínima para React + TypeScript:

typescript
// vite.config.ts con Vitest configurado
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      exclude: ["node_modules/", "src/test/"],
    },
  },
});
typescript
// src/test/setup.ts
import "@testing-library/jest-dom";

2. Tests unitarios con TypeScript

Los tests unitarios verifican piezas de lógica aisladas — funciones puras que reciben un input y retornan un output predecible sin efectos secundarios. Son los más rápidos de escribir, los primeros en fallar cuando algo se rompe y los más fáciles de debuggear porque no tienen dependencias externas.

Vitest infiere los tipos automáticamente desde el valor que devuelve la función testeada — no necesitas as Producto[] en cada assertion. vi.fn() crea una función mock tipada que registra cada llamada con sus argumentos, permitiendo aserciones sobre cómo fue invocada:

typescript
// Test de función pura tipada
import { describe, it, expect } from "vitest";
import { calcularTotal, aplicarDescuento } from "../utils/precio";

describe("calcularTotal", () => {
  it("suma los precios de todos los items", () => {
    const items: ItemCarrito[] = [
      { id: 1, nombre: "A", precio: 100, cantidad: 2 },
      { id: 2, nombre: "B", precio: 50, cantidad: 1 },
    ];
    expect(calcularTotal(items)).toBe(250);
  });

  it("retorna 0 para un carrito vacío", () => {
    expect(calcularTotal([])).toBe(0);
  });
});

3. Testing de componentes React

Los tests de componentes verifican lo que el usuario ve y puede hacer, no los detalles de implementación. React Testing Library fuerza este enfoque por diseño: no expone el estado interno de React ni el árbol de componentes — solo lo que el DOM renderiza.

Las queries de screen están ordenadas por prioridad de accesibilidad: getByRole > getByLabelText > getByText. Usar getByRole("button", { name: /guardar/i }) significa que el test también verifica que el componente es accesible para lectores de pantalla. userEvent simula interacciones reales del usuario (click, escritura, tab) en lugar del fireEvent de bajo nivel:

tsx
// Test de componente tipado
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import Boton from "../components/Boton";

describe("Boton", () => {
  it("llama a onClick cuando se hace clic", async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();

    render(<Boton label="Guardar" onClick={handleClick} />);

    await user.click(screen.getByRole("button", { name: "Guardar" }));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it("está deshabilitado cuando disabled=true", () => {
    render(<Boton label="Guardar" onClick={vi.fn()} disabled />);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

4. Mocks tipados

En los tests de componentes que hacen peticiones HTTP, el problema es de aislamiento: no quieres que un test falle porque el servidor no está disponible, o que los resultados cambien dependiendo de los datos reales de la base de datos.

vi.mock("ruta/del/modulo") intercepta todos los imports de ese módulo en el archivo de test y los reemplaza por funciones que no hacen nada por defecto. vi.mocked() es el type guard que convierte el import en su versión tipada de mock — sin él TypeScript no sabe que .mockResolvedValue() existe en el objeto. Esto da autocompletado completo al configurar el mock:

tsx
// Mock de servicio tipado
import { vi, describe, it, expect, beforeEach } from "vitest";
import * as productosService from "../services/productosService";

vi.mock("../services/productosService");
const mockedGetProductos = vi.mocked(productosService.getProductos);

describe("Productos component", () => {
  beforeEach(() => {
    mockedGetProductos.mockResolvedValue([
      { id: 1, nombre: "Mock Producto", precio: 99 },
    ]);
  });

  it("muestra los productos cargados", async () => {
    render(<Productos />);
    expect(await screen.findByText("Mock Producto")).toBeInTheDocument();
  });
});

5. Buenas prácticas TypeScript en React

Al terminar una semana de tests es buen momento para auditar la calidad del código que los tests verifican. El error más común en proyectos React + TypeScript es el any drift: un any al principio "para avanzar rápido" se copia, se propaga y dos semanas después la mitad de los componentes tienen tipos implícitos que el compilador no puede verificar.

La alternativa es unknown con type narrowing. unknown es tan flexible como any pero TypeScript te obliga a verificar el tipo antes de usarlo. El operador satisfies (TypeScript 4.9+) resuelve un caso distinto: valida que un objeto literal cumple una forma sin perder los tipos literales de sus valores:

typescript
// satisfies operator (TypeScript 4.9+)
const RUTAS = {
  home: "/",
  perfil: "/perfil",
  admin: "/admin",
} satisfies Record<string, string>;

// type narrowing en lugar de any
function procesarRespuesta(data: unknown): Producto {
  if (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "nombre" in data
  ) {
    return data as Producto;
  }
  throw new Error("Respuesta inválida");
}

Patrones de testing del proyecto CMS

vi.mock con hoisting

vi.mock se eleva antes de los imports — esto significa que nunca puedes usar una variable importada dentro del factory. La configuración del mock va en beforeEach:

typescript
// ✅ Correcto — el factory no usa variables externas
vi.mock('@/services/articulosService', () => ({
  articulosService: {
    getAll: vi.fn(),
    getBySlug: vi.fn(),
    resolverImagen: vi.fn((img) => img ?? '/uploads/default-cover.svg'),
  }
}))

// La configuración de qué devuelve cada test va en beforeEach
beforeEach(() => {
  vi.mocked(articulosService.getAll).mockResolvedValue({
    status: 'success',
    data: ARTICULOS_MOCK,
  })
})

window.matchMedia — ya está mockeado globalmente

jsdom no implementa window.matchMedia. El proyecto lo mockea en src/test/setup.ts. No lo añadas en tests individuales — ya está global:

typescript
// src/test/setup.ts — se ejecuta una vez para todos los tests
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query) => ({
    matches: false, media: query, onchange: null,
    addListener: vi.fn(), removeListener: vi.fn(),
    addEventListener: vi.fn(), removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})

Wrappers según el componente

typescript
// Componentes con <Link> o useNavigate → BrowserRouter
render(<ArticuloCard articulo={ARTICULOS_MOCK[0]} />, { wrapper: BrowserRouter })

// Componentes con useTema() → TemaProvider
render(<Header />, { wrapper: TemaProvider })

// Componentes con useAuth() → AuthProvider
render(<AdminLogin />, { wrapper: AuthProvider })

// Combinados — crea un wrapper compuesto
const Providers = ({ children }: { children: React.ReactNode }) => (
  <BrowserRouter><TemaProvider><AuthProvider>{children}</AuthProvider></TemaProvider></BrowserRouter>
)
render(<AdminListado />, { wrapper: Providers })

getByText vs findByText

typescript
// getBy → el elemento existe AHORA (sin async)
expect(screen.getByText('Mi primer artículo')).toBeInTheDocument()

// findBy → el elemento aparecerá DESPUÉS de una operación async
const titulo = await screen.findByText('Mi primer artículo')
expect(titulo).toBeInTheDocument()

Actividades prácticas

Actividad 1 — Tests unitarios (60 min) Escribir tests para 3 funciones puras del proyecto (cálculos de precio, validaciones, formateo de datos). Alcanzar 100% de cobertura de ramas.

Actividad 2 — Tests de componentes (75 min) Testear FormularioLogin: verificar que muestra errores de validación, que llama al servicio con los datos correctos y que redirige después del login exitoso.

Actividad 3 — Mock de API (60 min) Mockear las llamadas HTTP usando msw (Mock Service Worker). Testear el componente Productos en estados de carga, datos y error.

Actividad 4 — Revisión de código (45 min) Auditar el proyecto completo: eliminar todos los any, aplicar strict mode si no estaba activo, organizar tipos en archivos dedicados.


🛠 Proyecto CMS — Semana 10: Tests para el CMS

Esta semana escribes los tests que verifican que el CMS funciona correctamente. No testarás todo — testarás lo que importa: los componentes más críticos y los flujos que el usuario realiza realmente.

📁 Archivos de esta semana

code
frontend/
├── vite.config.ts                              ← ACTUALIZADO: añadir sección `test:`
└── src/
    └── test/                                   ← carpeta NUEVA esta semana
        ├── setup.ts                            ← NUEVO: mock global de window.matchMedia
        ├── components/
        │   └── __tests__/
        │       └── ArticuloCard.test.tsx       ← NUEVO
        ├── hooks/
        │   └── __tests__/
        │       └── useFormularioArticulo.test.ts  ← NUEVO
        └── pages/
            └── admin/
                └── __tests__/
                    └── AdminLogin.test.tsx     ← NUEVO

Regla de ubicación de tests: Los archivos .test.ts / .test.tsx van en __tests__/ junto al archivo que testean, con el mismo nombre + .test.ts. Así cualquier miembro del equipo sabe dónde buscar el test de cualquier módulo.

vitest/config (no vite): En vite.config.ts, importa defineConfig desde 'vitest/config', no desde 'vite' — de lo contrario las opciones test: no tienen tipos.

window.matchMedia ya está mockeado: El proyecto lo define en src/test/setup.ts. No lo repitas en archivos de test individuales.

Paso 1 — Configurar Vitest en el proyecto CMS

bash
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
typescript
// vite.config.ts — añadir sección test
export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
  },
});

Paso 2 — Test de ArticuloCard

El componente más reutilizado del CMS. Verifica que renderiza correctamente con un artículo real:

tsx
// src/components/__tests__/ArticuloCard.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { ArticuloCard } from "../ArticuloCard";
import { ARTICULOS_MOCK } from "@/data/mockData";

const articulo = ARTICULOS_MOCK[0];

describe("ArticuloCard", () => {
  it("muestra el título del artículo", () => {
    render(<ArticuloCard articulo={articulo} />);
    expect(screen.getByText(articulo.titulo)).toBeInTheDocument();
  });

  it("muestra el badge de categoría", () => {
    render(<ArticuloCard articulo={articulo} />);
    expect(screen.getByText(articulo.categoria.nombre)).toBeInTheDocument();
  });

  it("llama a onClick con el slug cuando se hace clic en el botón", async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();
    render(<ArticuloCard articulo={articulo} onClick={handleClick} />);

    await user.click(screen.getByRole("button", { name: /ver artículo/i }));

    expect(handleClick).toHaveBeenCalledWith(articulo.slug);
  });

  it("muestra el tiempo de lectura", () => {
    render(<ArticuloCard articulo={articulo} />);
    expect(screen.getByText(new RegExp(`${articulo.tiempoLectura} min`))).toBeInTheDocument();
  });
});

Paso 3 — Test del hook useFormularioArticulo

tsx
// src/hooks/__tests__/useFormularioArticulo.test.ts
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useFormularioArticulo } from '../useFormularioArticulo'

describe('useFormularioArticulo', () => {
  it('empieza con campos vacíos y esValido false', () => {
    const { result } = renderHook(() => useFormularioArticulo())
    expect(result.current.campos.titulo).toBe('')
    expect(result.current.esValido).toBe(false)
  })

  it('esValido true cuando el título tiene 3+ caracteres', () => {
    const { result } = renderHook(() => useFormularioArticulo())
    act(() => { result.current.setCampo('titulo', 'Mi artículo') })
    expect(result.current.esValido).toBe(true)
  })

  it('setCampo actualiza solo el campo indicado', () => {
    const { result } = renderHook(() => useFormularioArticulo())
    act(() => { result.current.setCampo('extracto', 'Texto extracto') })
    expect(result.current.campos.extracto).toBe('Texto extracto')
    expect(result.current.campos.titulo).toBe('') // los demás no cambian
  })

  it('reset vuelve todos los campos a vacío', () => {
    const { result } = renderHook(() => useFormularioArticulo())
    act(() => {
      result.current.setCampo('titulo', 'Algo')
      result.current.setCampo('extracto', 'Otro')
    })
    act(() => { result.current.reset() })
    expect(result.current.campos.titulo).toBe('')
    expect(result.current.campos.extracto).toBe('')
    expect(result.current.esValido).toBe(false)
  })
})

Paso 4 — Test del formulario de login

tsx
// src/pages/admin/__tests__/AdminLogin.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { AdminLogin } from "../AdminLogin";

// Mock del contexto de autenticación
const mockLogin = vi.fn();
vi.mock("@/context/AuthContext", () => ({
  useAuth: () => ({ login: mockLogin, isAuthenticated: false }),
}));

describe("AdminLogin", () => {
  it("muestra error con credenciales incorrectas", async () => {
    const user = userEvent.setup();
    mockLogin.mockResolvedValue(false);

    render(<MemoryRouter><AdminLogin /></MemoryRouter>);

    await user.type(screen.getByPlaceholderText("Email"), "malo@ejemplo.com");
    await user.type(screen.getByPlaceholderText("Contraseña"), "wrong");
    await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));

    expect(await screen.findByText(/credenciales incorrectas/i)).toBeInTheDocument();
  });

  it("no muestra error con credenciales correctas", async () => {
    const user = userEvent.setup();
    mockLogin.mockResolvedValue(true);

    render(<MemoryRouter><AdminLogin /></MemoryRouter>);

    await user.type(screen.getByPlaceholderText("Email"), "admin@blog.com");
    await user.type(screen.getByPlaceholderText("Contraseña"), "admin123");
    await user.click(screen.getByRole("button", { name: /iniciar sesión/i }));

    expect(screen.queryByText(/credenciales incorrectas/i)).not.toBeInTheDocument();
  });
});

Semana 9: El CMS está completo. Para el examen transversal integrarás todo lo construido semana a semana en un proyecto final funcionando, con tests, y lo presentarás defendiendo tus decisiones de arquitectura.


📋 Entregable de la semana

Para considerar completada la Semana 8, tu suite de tests debe demostrar:

  • Vitest configurado: npm test corre sin configuración adicional. El comando npm run coverage genera un reporte HTML en coverage/.
  • Mínimo 10 tests pasando: Cubriendo al menos ArticuloCard (render + interacción), useFormularioArticulo (setCampo, reset, esValido), AdminLogin (credenciales correctas e incorrectas) y NuevoArticulo (validación del formulario).
  • Al menos 1 test con mock de servicio: Un test que usa vi.mock para aislar el componente de la API real.
  • Audit de tipos aprobado: npm run build compila sin errores. No hay any en el código del proyecto. Al menos un uso de unknown con type narrowing en el manejo de errores.

📥 Descarga el entregable de esta semana

¿No lograste completar la semana o quieres comparar tu código con la solución? Descarga el estado final del proyecto (Semana 10 — proyecto completo):

⬇️ Descargar entregable Semana 10 — proyecto completo (.zip)

Qué incluye: el proyecto CMS Blog completo — frontend React + TypeScript con Vitest configurado y 29 tests pasando, backend Express + Prisma 7 con arquitectura MVC completa, monorepo con npm run dev funcional.

Setup después de descomprimir:

bash
cd blog-cms
npm install
cd frontend && npm install && cd ..
cd backend && pnpm install
# Crea backend/.env con: PORT=3001 y DATABASE_URL="postgresql://..."
npx prisma migrate dev && pnpm seed
cd ..
npm run dev              # frontend (5173) + backend (3001)
cd frontend && npm run test:run   # ejecutar los 29 tests