Las pruebas de componentes asíncronos en React afrontan invariablemente el desafío del aislamiento de la capa de red. Tradicionalmente, los desarrolladores optaban por mockear directamente funciones globales o clientes de terceros: vi.spyOn(global, 'fetch') o vi.mock('axios').
El mockeo directo de clientes de red es un antipatrón arquitectónico fundamental por múltiples razones:
Content-Type, codificación de parámetros URL y procesamiento de FormData quedan en la sombra.axios a fetch nativo o incorporar un cliente GraphQL invalida la base de código de las pruebas por completo, a pesar de que el contrato con la API del backend permanezca idéntico.Mock Service Worker (MSW) versión 2.x supuso un salto cualitativo al adoptar íntegramente los estándares W3C Fetch API (Request, Response, Headers). La aplicación ejecuta llamadas fetch() auténticas sin alterar en absoluto el código de producción, mientras que MSW las intercepta de forma transparente en la frontera de entrada/salida de red.
En el entorno de pruebas (Node.js con Vitest 4+ y entornos happy-dom/jsdom), el Service Worker del navegador no existe. MSW 2.x emplea el paquete @mswjs/interceptors, conectándose a los módulos de bajo nivel de Node.js (http, https, fetch global, XMLHttpRequest).
Cuando un componente de React, un hook de TanStack Query o una instancia de SWR lanza una petición, esta se intercepta antes de alcanzar la pila de red del sistema operativo. MSW coteja la URL y el método con los manejadores registrados (Request Handlers). Al encontrar una coincidencia, genera una respuesta W3C auténtica mediante la clase HttpResponse.
+-----------------------------------------------------------------------+
| React 19 Component / TanStack Query (fetch('/api/v1/projects')) |
+-----------------------------------------------------------------------+
│
▼ (Invocación estándar de Fetch API)
+-----------------------------------------------------------------------+
| Node.js / happy-dom Environment |
| [@mswjs/interceptors: intercepción a nivel de sockets] |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| MSW 2.x Request Matcher Pipeline |
| 1. Evaluar sustituciones locales (server.use) |
| 2. Cotejar handlers base (http.get, http.post) |
+-----------------------------------------------------------------------+
│ │
(Coincidencia) (Sin coincidencia)
▼ ▼
+-------------------------------+ +---------------------------+
| HttpResponse.json(mockData) | | onUnhandledRequest |
| - Genera un objeto estándar | | - 'error': fallo de test |
| Response (W3C) | | - 'bypass': acceso a red |
+-------------------------------+ +---------------------------+En MSW 2.x se abandonó por completo la sintaxis heredada de rest.* y los closures res(ctx.status(...), ctx.json(...)) para adoptar los estándares web modernos:
| Característica | MSW 1.x Legado (Obsoleto) | MSW 2.x Moderno (Estándar) |
| :--- | :--- | :--- |
| Espacio de nombres | rest.get(), rest.post() | http.get(), http.post() |
| Constructor de respuesta | res(ctx.status(200), ctx.json(data)) | HttpResponse.json(data, { status: 200 }) |
| Parámetros de petición | req.params, req.body | { request, params, cookies }, await request.json() |
| Fallo de red | res.networkError('Failed') | HttpResponse.error() |
| Retardo de respuesta | ctx.delay(200) | await delay(200) |
http#import { http, HttpResponse, delay } from 'msw';
export const handlers = [
// Intercepción de petición GET con parámetros de ruta
http.get('/api/users/:userId', ({ params }) => {
const { userId } = params;
return HttpResponse.json({
id: userId,
name: 'Senior Architect',
role: 'staff',
});
}),
// Intercepción de POST con extracción del cuerpo JSON
http.post('/api/auth/login', async ({ request }) => {
const body = (await request.json()) as { email?: string };
if (body.email === '[email protected]') {
return HttpResponse.json(
{ error: 'Account Suspended' },
{ status: 403 }
);
}
return HttpResponse.json({ token: 'mock-jwt-token-2026' });
}),
// Simulación de fallo de hardware o corte de red (DNS / Connection Refused)
http.get('/api/telemetry', () => {
return HttpResponse.error();
}),
];setupFiles)#La gestión del ciclo de vida del servidor de intercepción se centraliza en el archivo de inicialización de Vitest:
import { setupServer } from 'msw/node';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// 1. Iniciar la intercepción antes de ejecutar la suite
beforeAll(() => {
server.listen({
// Lanzar error fatal si se detecta una petición no interceptada
onUnhandledRequest: 'error',
});
});
// 2. Limpiar las sobreescrituras server.use() tras cada test
afterEach(() => {
server.resetHandlers();
});
// 3. Detener el servidor y restaurar la pila de red al finalizar
afterAll(() => {
server.close();
});El componente solicita datos al montarse, presenta un indicador de carga y seguidamente renderiza la colección devuelta.
import { describe, it, expect } from 'vitest';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import React, { useState, useEffect } from 'react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
interface Project {
readonly id: string;
readonly name: string;
readonly stars: number;
}
function ProjectList() {
const [projects, setProjects] = useState<readonly Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch('/api/v1/projects')
.then((res) => res.json() as Promise<readonly Project[]>)
.then((data) => {
setProjects(data);
setIsLoading(false);
});
}, []);
if (isLoading) {
return <div role="status">Cargando datos del proyecto...</div>;
}
return (
<section aria-label="Catálogo de proyectos">
<h2>Lista de proyectos</h2>
<ul>
{projects.map((p) => (
<li key={p.id}>
<span>{p.name}</span> — <strong>{p.stars} ★</strong>
</li>
))}
</ul>
</section>
);
}
describe('ProjectList with MSW v2', () => {
it('muestra el estado de carga y renderiza los elementos desde la respuesta de red', async () => {
// Registro de respuesta simulada
server.use(
http.get('/api/v1/projects', () => {
return HttpResponse.json([
{ id: '1', name: 'Next.js 15 Starter', stars: 450 },
{ id: '2', name: 'Vitest Benchmark Suite', stars: 280 },
]);
})
);
render(<ProjectList />);
// 1. Verificar la presencia inicial del spinner
const loader = screen.getByRole('status');
expect(loader).toHaveTextContent('Cargando datos del proyecto...');
// 2. Esperar a que el spinner desaparezca del DOM
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 3. Comprobar la representación de los elementos
expect(
screen.getByRole('region', { name: 'Catálogo de proyectos' })
).toBeInTheDocument();
expect(screen.getByText('Next.js 15 Starter')).toBeInTheDocument();
expect(screen.getByText('Vitest Benchmark Suite')).toBeInTheDocument();
});
});server.use() (Pruebas de Errores 500 y Fallos de Red)#El método server.use() permite modificar la respuesta de un endpoint dentro de un bloque it individual para evaluar situaciones límite como caídas del backend o denegaciones de acceso.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import React, { useState, useEffect } from 'react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
function UserProfile({ userId }: { readonly userId: string }) {
const [profile, setProfile] = useState<{ readonly name: string } | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(async (res) => {
if (!res.ok) {
throw new Error(`HTTP Error: ${res.status}`);
}
return res.json() as Promise<{ readonly name: string }>;
})
.then((data) => setProfile(data))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to fetch user';
setErrorMsg(msg);
});
}, [userId]);
if (errorMsg) {
return <div role="alert">Error: {errorMsg}</div>;
}
if (!profile) {
return <p>Iniciando perfil...</p>;
}
return <h1>Perfil: {profile.name}</h1>;
}
describe('UserProfile Error Boundaries and Server Overrides', () => {
it('gestiona correctamente un 500 Internal Server Error mediante server.use()', async () => {
// Sustitución temporal del endpoint exclusiva para esta prueba
server.use(
http.get('/api/users/:userId', () => {
return HttpResponse.json(
{ message: 'Database Connection Timeout' },
{ status: 500 }
);
})
);
render(<UserProfile userId="999" />);
// Esperar de forma asíncrona la aparición de la alerta
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toHaveTextContent('Error: HTTP Error: 500');
});
it('emula un fallo físico de conexión de red (HttpResponse.error)', async () => {
server.use(
http.get('/api/users/:userId', () => {
// Simula la pérdida física de socket (Failed to fetch / TypeError)
return HttpResponse.error();
})
);
render(<UserProfile userId="500" />);
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toBeInTheDocument();
});
});Los manejadores de MSW pueden validar cabeceras, parámetros y cuerpos transmitidos por el cliente, emitiendo un estado 422 Unprocessable Entity o 201 Created según la validez del contrato.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
interface IssuePayload {
readonly title: string;
readonly severity: 'low' | 'critical';
}
function CreateIssueForm() {
const [title, setTitle] = useState('');
const [severity, setSeverity] = useState<'low' | 'critical'>('low');
const [successId, setSuccessId] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, severity }),
});
if (res.ok) {
const data = (await res.json()) as { id: string };
setSuccessId(data.id);
}
};
return (
<form onSubmit={handleSubmit} aria-label="Crear incidencia">
<label htmlFor="issue-title">Título del problema</label>
<input
id="issue-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<label htmlFor="issue-sev">Severidad</label>
<select
id="issue-sev"
value={severity}
onChange={(e) => setSeverity(e.target.value as 'low' | 'critical')}
>
<option value="low">Baja</option>
<option value="critical">Crítica</option>
</select>
<button type="submit">Enviar incidencia</button>
{successId && <div role="status">Incidencia creada: #{successId}</div>}
</form>
);
}
describe('CreateIssueForm Network Mutation', () => {
it('envía la petición POST con serialización JSON válida', async () => {
const user = userEvent.setup();
let capturedBody: IssuePayload | null = null;
server.use(
http.post('/api/issues', async ({ request }) => {
capturedBody = (await request.json()) as IssuePayload;
return HttpResponse.json({ id: 'ISSUE-4096' }, { status: 201 });
})
);
render(<CreateIssueForm />);
await user.type(screen.getByLabelText('Título del problema'), 'Memory Leak in Fiber');
await user.selectOptions(screen.getByLabelText('Severidad'), 'critical');
await user.click(screen.getByRole('button', { name: 'Enviar incidencia' }));
// Esperar mensaje de confirmación
const statusMsg = await screen.findByRole('status');
expect(statusMsg).toHaveTextContent('Incidencia creada: #ISSUE-4096');
// Comprobar la carga capturada
expect(capturedBody).toEqual({
title: 'Memory Leak in Fiber',
severity: 'critical',
});
});
});delay() para Proteger contra Race Conditions#Al evaluar búsquedas predictivas o clics rápidos consecutivos, confirmar que las respuestas desactualizadas se descartan con AbortController evita condiciones de carrera.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState, useEffect } from 'react';
import { http, HttpResponse, delay } from 'msw';
import { server } from '../test/mocks/server';
function SearchBox() {
const [query, setQuery] = useState('');
const [result, setResult] = useState<string>('');
useEffect(() => {
if (!query) return;
const controller = new AbortController();
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then((res) => res.json() as Promise<{ text: string }>)
.then((data) => setResult(data.text))
.catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') {
// La petición se canceló deliberadamente
return;
}
});
return () => {
controller.abort();
};
}, [query]);
return (
<div>
<input
aria-label="Buscar"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div role="status">Resultado: {result}</div>
</div>
);
}
describe('SearchBox Race Condition Protection', () => {
it('descarta la respuesta lenta antigua a favor de la petición rápida más reciente', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/search', async ({ request }) => {
const url = new URL(request.url);
const q = url.searchParams.get('q');
// La primera consulta "slow" sufre 200ms de latencia
if (q === 'slow') {
await delay(200);
return HttpResponse.json({ text: 'Slow Result' });
}
// La segunda consulta "fast" responde de inmediato
if (q === 'fast') {
return HttpResponse.json({ text: 'Fast Result' });
}
return HttpResponse.json({ text: 'Default' });
})
);
render(<SearchBox />);
const input = screen.getByLabelText('Buscar');
// Escritura rápida sucesiva
await user.type(input, 'slow');
await user.clear(input);
await user.type(input, 'fast');
// Debe mostrar 'Fast Result', habiendo cancelado 'Slow Result'
const status = await screen.findByText('Resultado: Fast Result');
expect(status).toBeInTheDocument();
expect(screen.queryByText('Resultado: Slow Result')).not.toBeInTheDocument();
});
});En MSW 2.x se eliminó por completo el objeto rest. Escribir rest.get(url, (req, res, ctx) => res(ctx.json(...))) genera una excepción de tipo TypeError: rest is not defined.
// ❌ ANTIPATRÓN (MSW 1.x): Sintaxis desfasada
rest.get('/api/user', (req, res, ctx) => res(ctx.json({ id: 1 })));
// ✅ SOLUCIÓN (MSW 2.x): Estándares web modernos
http.get('/api/user', () => HttpResponse.json({ id: 1 }));Si un test aplica server.use() para validar un error 500 y no se invoca server.resetHandlers() en afterEach, esa respuesta filtrará hacia las pruebas posteriores causando fallos inesperados.
// ❌ ANTIPATRÓN: Sin limpieza de handlers entre pruebas
afterEach(() => {
cleanup();
});
// ✅ SOLUCIÓN: Limpieza estricta de sobreescrituras en memoria
afterEach(() => {
cleanup();
server.resetHandlers();
});La opción 'bypass' permite que peticiones no controladas salgan hacia la red real o fallen silenciosamente en Node.js, escondiendo errores de escritura en los endpoints.
// ❌ ANTIPATRÓN: Errores tipográficos (/api/useers en vez de /api/users) no se advertirán
server.listen({ onUnhandledRequest: 'bypass' });
// ✅ SOLUCIÓN: Modo estricto con fallo inmediato ante peticiones imprevistas
server.listen({ onUnhandledRequest: 'error' });En el navegador fetch('/api/data') resuelve respecto a window.location.origin. En entornos mínimos de Node.js sin configuración de origen, las rutas relativas pueden lanzar TypeError: Invalid URL.
// ❌ RIESGO: Rutas relativas sin origin en Node puro
fetch('/api/data');
// ✅ SOLUCIÓN: Garantizar que happy-dom/jsdom inicialice window.location.origin
// En los handlers mantenga rutas relativas '/api/data',
// y vincule la configuración del test runner a http://localhost:3000Definición del listado de manejadores (http.get, http.post) en un módulo mocks/handlers.ts.
Creación de la instancia setupServer(...handlers) y arranque con server.listen({ onUnhandledRequest: 'error' }).
Uso puntual de server.use() en tests concretos para emular estados 401, 500, delay o fallos de conexión.
Los componentes invocan fetch con normalidad, mientras MSW intercepta el tráfico a nivel de socket.
Llamada obligatoria a server.resetHandlers() en afterEach para asegurar aislamiento total.