---
name: nextjs-testing
description: >
  Testing Next.js con Vitest, React Testing Library, Playwright y MSW.
  Cubre Server Components, Server Actions, page objects E2E y mocking de API.
  Cargar cuando se escriban tests de componentes, integración o E2E en Next.js.
version: "1.1.0"
evolved: true
evolved-from: "1.0.1"
evolved-at: "2026-05-12"
evolved-by: "aprender"
evolved-note: "Backport L-139 SIGM: tests con vi.stubGlobal('fetch') no validan contrato real backend↔frontend; añadir test de contrato con codegen api-types.ts; previo L-106 getByText vs getAllByText"
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para tests React puros fuera del contexto de Next.js (Vite, CRA, Remix) — los mocks del router de Next.js y los Server Components son específicos del framework."
  - "No cargar para implementar la lógica del componente que se está testando — si aún no está implementado, cargar `nextjs-experto` primero; los tests se escriben sobre código existente."
  - "No cargar para configurar Playwright en un proyecto no-Next.js — Playwright funciona con cualquier web app, pero este skill cubre la configuración específica de Next.js con `next/server` y Server Actions."
  - "No cargar para cobertura de código o análisis de calidad de tests existentes — para métricas de calidad cargar `checklist-calidad`."
evolvable: true  # default para skill estandar
---
# Next.js Testing — Vitest, RTL, Playwright y MSW

El App Router introduce complejidad de testing: Server Components son async,
los Server Actions requieren contexto de servidor, y el router de Next.js necesita
mocks específicos. Este skill cubre la configuración y los patrones para cada tipo
de test en proyectos Next.js modernos.

## Cuándo cargar este skill

Invoca `Skill("nextjs-testing")` cuando:

- Se configuren Vitest o Jest para un proyecto Next.js
- Se escriban tests de componentes con React Testing Library
- Se implementen tests E2E con Playwright
- Se mockeen APIs externas con MSW en tests
- Se prueben Server Actions o route handlers

## Cuándo NO cargar

- El proyecto usa React sin Next.js (Vite, CRA, Remix) — los mocks de `next/navigation` y los patrones de Server Components son específicos de Next.js.
- El componente que se quiere testear no está implementado aún — primero implementar con `nextjs-experto`, luego escribir tests.
- La pregunta es sobre configurar Playwright en un proyecto no-Next.js — Playwright funciona con cualquier web app pero este skill cubre integración con `next/server` y Server Actions.
- La pregunta es sobre cobertura de código o análisis de calidad de tests existentes — para métricas de calidad cargar `checklist-calidad`.

## Conceptos clave

### Qué testear en Next.js

Server Components: testear la lógica de transformación de datos y el HTML
renderizado. Client Components: testear interacciones, estado y callbacks.
Server Actions: testear validación, mutaciones y revalidación. E2E: testear
flujos completos de usuario en el browser real.

### Vitest vs Jest

Vitest es la opción moderna: compatible con Vite, más rápido, soporte nativo
de ESM. Jest requiere más configuración para Next.js con App Router. Ambos son
compatibles con React Testing Library.

### MSW (Mock Service Worker)

MSW intercepta requests HTTP a nivel de Service Worker (browser) o a nivel de
Node.js (tests). Permite definir handlers una vez y usarlos en tests de unidad,
integracion y en el browser de desarrollo.

### Estrategia de cobertura por capas

Testear Next.js requiere una estrategia sistemática por capas. Cada capa cubre
un tipo de riesgo distinto — no son intercambiables:

| Capa | Qué testea | Herramienta | Ejemplo |
|------|-----------|-------------|---------|
| **Componente** | UI aislada, interacciones, estados visuales | Vitest + RTL | `render(<Boton />)` + click + assert texto |
| **Página (RSC)** | Data fetching + transformación + render | Vitest (async) | `await Pagina({params})` + assert contenido |
| **Hook** | Lógica reutilizable, efectos, estado | `renderHook` | `renderHook(() => useFiltros())` + act |
| **Integración** | Server Actions, validación + mutación + revalidación | Vitest + mocks BD | `await crearProducto(formData)` + assert BD |
| **E2E** | Flujos completos de usuario en browser real | Playwright | Navegar → llenar form → submit → verificar |

**Regla de proporción**: ~60% componente/página, ~25% integración, ~15% E2E.
Los tests E2E son lentos y frágiles — cubrir solo los flujos críticos de negocio.
Los tests de componente son baratos — cubrir todas las variantes de estado.

## Reglas obligatorias

### Configurar next/jest o Vitest correctamente antes de escribir cualquier test

```javascript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
    plugins: [react()],
    test: {
        environment: 'jsdom',
        setupFiles: ['./tests/setup.ts'],
        globals: true,
    },
    resolve: {
        alias: { '@': path.resolve(__dirname, './') },
    },
});
```

### Mockear next/navigation y next/router SIEMPRE en tests de componentes

```typescript
// tests/setup.ts
vi.mock('next/navigation', () => ({
    useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
    useSearchParams: () => new URLSearchParams(),
    usePathname: () => '/',
}));
```

Sin este mock, cualquier componente que importe de `next/navigation` fallará.

### MSW handlers en archivo centralizado, no inline en cada test

```typescript
// tests/msw/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
    http.get('/api/productos', () => {
        return HttpResponse.json({ data: [{ id: 1, nombre: 'Laptop' }] });
    }),
    http.post('/api/productos', async ({ request }) => {
        const body = await request.json();
        return HttpResponse.json({ data: { id: 99, ...body } }, { status: 201 });
    }),
];
```

## Patrones recomendados

### Testing de Client Component con interacciones

```typescript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormularioProducto from '@/components/FormularioProducto';

describe('FormularioProducto', () => {
    it('envia el formulario con datos validos', async () => {
        const user = userEvent.setup();
        const mockSubmit = vi.fn();

        render(<FormularioProducto onSubmit={mockSubmit} />);

        await user.type(screen.getByLabelText('Nombre'), 'Laptop Pro');
        await user.type(screen.getByLabelText('Precio'), '25000');
        await user.click(screen.getByRole('button', { name: /guardar/i }));

        await waitFor(() => {
            expect(mockSubmit).toHaveBeenCalledWith({
                nombre: 'Laptop Pro',
                precio: 25000,
            });
        });
    });

    it('muestra error cuando el nombre esta vacio', async () => {
        const user = userEvent.setup();
        render(<FormularioProducto onSubmit={vi.fn()} />);

        await user.click(screen.getByRole('button', { name: /guardar/i }));

        expect(screen.getByText('El nombre es requerido')).toBeInTheDocument();
    });
});
```

### Testing de Server Component (async)

```typescript
import { render, screen } from '@testing-library/react';
import ListaProductos from '@/app/productos/page';

// Mockear la función de fetch de datos
vi.mock('@/lib/api', () => ({
    obtenerProductos: vi.fn().mockResolvedValue([
        { id: 1, nombre: 'Laptop', precio: 25000 },
        { id: 2, nombre: 'Mouse', precio: 500 },
    ]),
}));

it('renderiza lista de productos', async () => {
    const ui = await ListaProductos({});
    render(ui);

    expect(screen.getByText('Laptop')).toBeInTheDocument();
    expect(screen.getByText('Mouse')).toBeInTheDocument();
});
```

### Testing de Server Action

```typescript
import { crearProducto } from '@/app/actions/productos';
import { db } from '@/lib/db';

vi.mock('@/lib/db');
vi.mock('@/lib/auth', () => ({
    auth: vi.fn().mockResolvedValue({ user: { id: '1', rol: 'admin' } }),
}));

describe('crearProducto', () => {
    it('crea producto con datos validos', async () => {
        const formData = new FormData();
        formData.append('nombre', 'Laptop Pro');
        formData.append('precio', '25000');

        const resultado = await crearProducto(formData);

        expect(resultado).not.toHaveProperty('error');
        expect(db.producto.create).toHaveBeenCalled();
    });

    it('retorna errores de validacion con datos invalidos', async () => {
        const formData = new FormData();
        formData.append('nombre', ''); // invalido

        const resultado = await crearProducto(formData);

        expect(resultado?.error?.nombre).toBeDefined();
    });
});
```

### Playwright Page Object para E2E

```typescript
// tests/e2e/pages/ProductosPage.ts
import { Page, Locator } from '@playwright/test';

export class ProductosPage {
    readonly page: Page;
    readonly botonNuevo: Locator;
    readonly tablaProductos: Locator;

    constructor(page: Page) {
        this.page = page;
        this.botonNuevo = page.getByRole('button', { name: 'Nuevo producto' });
        this.tablaProductos = page.getByRole('table', { name: 'Productos' });
    }

    async navegar() {
        await this.page.goto('/dashboard/productos');
    }

    async crearProducto(nombre: string, precio: number) {
        await this.botonNuevo.click();
        await this.page.getByLabel('Nombre').fill(nombre);
        await this.page.getByLabel('Precio').fill(precio.toString());
        await this.page.getByRole('button', { name: 'Guardar' }).click();
    }
}

// tests/e2e/productos.spec.ts
import { test, expect } from '@playwright/test';
import { ProductosPage } from './pages/ProductosPage';

test('admin puede crear un producto', async ({ page }) => {
    const productosPage = new ProductosPage(page);
    await productosPage.navegar();
    await productosPage.crearProducto('Laptop Pro', 25000);

    await expect(page.getByText('Laptop Pro')).toBeVisible();
});
```

### MSW en tests de integración con servidor Node

```typescript
// tests/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './msw/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

## Anti-patrones conocidos

### getByTestId como primera opción

```typescript
// MAL — acoplado a detalles de implementación
screen.getByTestId('boton-submit');

// BIEN — accesible y más semántico
screen.getByRole('button', { name: /guardar/i });
screen.getByLabelText('Email');
screen.getByText('Confirmar pedido');
```

Prioridad: getByRole > getByLabelText > getByPlaceholderText > getByText > getByTestId.

### Snapshot tests para componentes que cambian frecuentemente

Los snapshots se desactualizan y se actualizan sin revisar el cambio real.
Preferir assertions específicas sobre el contenido crítico.

## Gotchas / Errores comunes no obvios

**`vi.mock('next/navigation')` no afecta si se importa antes del mock**: si el componente bajo test importa `useRouter` en el nivel de módulo antes de que `vi.mock` se evalúe, el mock no se aplica. Causa: Vitest hoistea los `vi.mock()` al inicio del archivo, pero en algunos setups con transformaciones el orden puede fallar. Fix: asegurarse que `vi.mock('next/navigation', ...)` está en el archivo de setup global (`tests/setup.ts`) y se carga antes de cualquier import del componente.

**Server Component async no se puede renderizar directamente con `render()`**: `render(<MiServerComponent />)` falla con `Objects are not valid as a React child (found: [object Promise])` porque RTL no sabe manejar un componente async. Causa: React Testing Library en su versión estable no soporta async Server Components directamente. Fix: `await` el componente primero (`const ui = await MiServerComponent({params})`), luego `render(ui)`.

**MSW con `onUnhandledRequest: 'error'` falla en tests que hacen fetches a rutas internas de Next.js**: el test lanza error porque `_next/static/...` o rutas internas del framework generan requests que MSW no tiene handlers para. Causa: Next.js en modo test puede generar requests internos. Fix: usar `onUnhandledRequest: 'warn'` o agregar handlers para las rutas internas de Next.js, o filtrar con una función: `onUnhandledRequest: (req) => { if (!req.url.includes('_next')) throw new Error(...) }`.

**`userEvent.setup()` debe llamarse fuera del `it()` o re-instanciarse por test**: compartir una instancia de `userEvent` entre tests causa que el estado del puntero/teclado se acumule entre tests, causando comportamiento impredecible. Causa: `userEvent.setup()` crea un estado de dispositivo virtual que persiste. Fix: llamar `const user = userEvent.setup()` dentro de cada `it()` o en `beforeEach`, no al nivel de `describe`.

**`getByText` falla con "found multiple elements" cuando el mismo texto aparece en varios nodos legítimos** [CONFIRMADO x2 en SIGM Opción C, F1.1 y F1.2]: en componentes con resumen + total, o heading + botón con texto similar, el monto `$5,000.00` o la palabra "Forma de pago" aparece en 2+ elementos del DOM. `getByText` lanza error. Fix por precisión semántica, en orden de preferencia:

```tsx
// ❌ Falla cuando hay duplicación legítima
expect(screen.getByText('$5,000.00')).toBeInTheDocument()

// ✅ Opción 1 — usar getByRole con accessibility name (más precisa)
expect(screen.getByRole('heading', { name: /forma de pago/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /agregar forma de pago/i })).toBeInTheDocument()

// ✅ Opción 2 — getAllByText cuando todas las apariciones son válidas
const montos = screen.getAllByText('$5,000.00')
expect(montos).toHaveLength(2)  // subtotal + total

// ✅ Opción 3 — within() para scope al contenedor
const total = within(screen.getByTestId('seccion-total')).getByText('$5,000.00')
```

Regla práctica: si el texto aparece duplicado, casi siempre la intención del test es verificar UNO de los dos elementos por su rol semántico (`heading` vs `button` vs `cell`). `getByRole` con `name` lo resuelve sin recurrir a `data-testid`.

## Checklist de verificación

- [ ] Vitest/Jest configurado con next/jest transform o plugin react
- [ ] next/navigation mockeado en setup global de tests
- [ ] MSW handlers en archivo centralizado
- [ ] Tests de Client Component usan userEvent (no fireEvent para interacciones)
- [ ] Server Actions testeadas con FormData real
- [ ] E2E con Page Objects para reutilizar selectores
- [ ] `onUnhandledRequest: 'error'` en MSW para detectar fetches sin handler

## Anti-patrón crítico — Mocks `fetch` globales NO validan contrato con backend

**Síntoma** (L-139 SIGM 2026-05-12): la suite Vitest pasa 538/538 con `vi.stubGlobal('fetch')` retornando responses arbitrarios. Cero confianza real en el contrato API porque los mocks aceptan cualquier shape de request/response.

**Caso real**: `useCajaCobro.ts` construía `CobroConsolidadoRequest` con campos `contribuyente_id`/`caja_id`/`modalidad_facturacion`/`forma_pago: "01"` (códigos SAT). El backend espera `persona_id`/`corte_caja_id`/`modalidad`/`forma_pago: Literal["efectivo","tarjeta","documento"]` + desglose `monto_efectivo/tarjeta/documento/monto_total_declarado`. El cobro NO funcionaba end-to-end. Tests pasaban porque el mock global aceptaba el payload mal-formado sin validación.

**Causa raíz**: `vi.stubGlobal('fetch')` reemplaza fetch con un mock que no verifica el shape del body — solo registra que fue llamado.

```typescript
// MAL — el test pasa con cualquier shape de body
beforeEach(() => {
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ data: { folio_operacion: 'X', total: '100' } })
  }))
})

it('envía cobro', async () => {
  await ejecutarCobro(...)
  expect(fetch).toHaveBeenCalledWith(
    '/api/v1/caja/cobro',
    expect.objectContaining({ method: 'POST' })  // no valida body
  )
})
```

```typescript
// BIEN — agregar test de contrato que valide shape contra codegen
import type { components } from '@/lib/api-types'
import { z } from 'zod'

// Schema zod derivado del codegen (regenerable con openapi-zod-client)
const CobroConsolidadoRequestSchema = z.object({
  persona_id: z.string().uuid(),
  lineas: z.array(z.object({...})).min(1),
  modalidad: z.enum(['consolidado', 'por_concepto']),
  forma_pago: z.enum(['efectivo', 'tarjeta', 'documento']),
  monto_efectivo: z.union([z.number(), z.string()]),
  // ... resto de campos
})

it('payload de cobro cumple el contrato del backend', async () => {
  let payloadCapturado: unknown
  vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url, opts) => {
    payloadCapturado = JSON.parse(opts.body)
    return { ok: true, json: async () => ({ data: {...}, meta: {} }) }
  }))

  await ejecutarCobro(...)

  // VALIDA QUE EL SHAPE COINCIDE CON EL BACKEND
  const r = CobroConsolidadoRequestSchema.safeParse(payloadCapturado)
  expect(r.success).toBe(true)
  if (!r.success) console.error(r.error.issues)
})
```

**Alternativa simple — solo TypeScript** (sin zod):

```typescript
// Asegurar que el código produce un objeto asignable al tipo del codegen
type ExpectedRequest = components['schemas']['CobroConsolidadoRequest']

it('payload tipo TypeScript matchea backend', async () => {
  let payload: ExpectedRequest | undefined
  vi.stubGlobal('fetch', vi.fn().mockImplementation(async (url, opts) => {
    // Si TS compila sin error en esta asignación, el shape es compatible
    payload = JSON.parse(opts.body) as ExpectedRequest
    return { ok: true, json: async () => ({...}) }
  }))

  await ejecutarCobro(...)
  expect(payload?.persona_id).toBeDefined()  // campo del schema correcto
})
```

**Reglas para evitar el anti-patrón**:

1. **Mocks de `fetch` NO son sustituto de tests de contrato**. Útiles para UI y flujo, pero ciegos al shape.
2. **Agregar al menos 1 test de contrato por módulo** que use mockup que captura el body y lo valida contra schema (zod del codegen, TypeScript tipo del codegen, o JSON Schema de OpenAPI).
3. **Regenerar `api-types.ts` ante cualquier cambio de schema backend** y dejar que `tsc` falle si los tipos no cuadran.
4. **MSW (Mock Service Worker) > `vi.stubGlobal('fetch')`** cuando se quiere semántica más realista — MSW intercepta a nivel de red y permite definir handlers tipados.

## Referencias

- [Testing Next.js](https://nextjs.org/docs/app/building-your-application/testing)
- [MSW](https://mswjs.io/docs)
- [Playwright](https://playwright.dev/docs/intro)
- [openapi-zod-client](https://github.com/astahmer/openapi-zod-client) — generador de schemas zod desde OpenAPI

---
*Skill creado con swl:crear-skill el 2026-03-31. Versión 1.1.0 (2026-05-12).*
