Configurando MSW v2 en React Native para testing
Volver al blog

Configurando MSW v2 en React Native

Por qué MSW en vez de mocks manuales

La mayoría de los proyectos React Native mockean su capa de API con jest.fn(). Mockeas fetch o tu instancia de Axios, defines lo que devuelve, y testeas contra eso.

Funciona. Hasta que no.

El problema: estás testeando la interacción de tu código con un mock, no con una capa HTTP. Si tu cliente de API cambia cómo construye URLs, agrega headers o maneja reintentos, el mock no detecta la regresión. (Una capa de validación de respuestas en runtime con Zod tampoco se ejercitaría). El mock siempre devuelve lo que le dijiste, sin importar lo que el código realmente envió.

Mock Service Worker (MSW) intercepta las peticiones a nivel de red. Tu código hace llamadas HTTP reales. MSW las captura antes de que salgan del proceso y devuelve tus respuestas mockeadas. Todo lo que hay entre tu componente y la red se ejercita: el thunk de Redux, los interceptores de Axios, el manejo de errores, el parseo de la respuesta.

Los mocks manuales reemplazan tu código. MSW reemplaza la red. El código corre exactamente como lo haría en un dispositivo, hasta el punto donde la petición habría salido.

Instalación

MSW v2 funciona en React Native a través del servidor de Node.js (para tests de Jest). El service worker del navegador no aplica para mobile.

yarn add -D msw

Eso es todo. Sin polyfills, sin cambios en la config de Metro, sin linking de módulos nativos.

El servidor

Crea src/test-utils/msw/server.ts:

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

Tres líneas. El servidor toma tus handlers por defecto (respuestas exitosas) e intercepta las peticiones que matchean.

Conectándolo con Jest

En tu jest.setup.ts (o .js), añade el ciclo de vida de MSW:

import { server } from './src/test-utils/msw/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
HookQué hace
beforeAllInicia el servidor antes de que corra cualquier test
afterEachResetea los handlers a los defaults entre tests (para que los overrides de un test no se filtren)
afterAllApaga el servidor después de que todos los tests terminan

La opción onUnhandledRequest: 'warn' registra un warning si tu código hace una petición que ningún handler coincide. Esto atrapa handlers faltantes temprano en vez de dejar que los tests fallen con errores de red crípticos.

Escribiendo handlers

Cada handler es una función que matchea un método HTTP y una URL, y devuelve una respuesta.

Un handler básico para una REST API:

import { http, HttpResponse } from 'msw';

const BASE_URL = 'https://api.example.com';

export const handlers = [
  http.get(`${BASE_URL}/items`, () => {
    return HttpResponse.json([
      { id: 1, name: 'Item One' },
      { id: 2, name: 'Item Two' },
    ]);
  }),

  http.get(`${BASE_URL}/items/:id`, ({ params }) => {
    const { id } = params;
    return HttpResponse.json({ id: Number(id), name: `Item ${id}` });
  }),

  http.post(`${BASE_URL}/items`, async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 3, ...body }, { status: 201 });
  }),
];

Algunas cosas que conviene saber: los helpers por método (http.get, http.post y los demás) matchean el verbo HTTP, los parámetros de URL como :id se extraen en params automáticamente, el body del request llega vía await request.json(), y HttpResponse.json() devuelve JSON tipado con el código de estado que le pases.

Handler sets para cada escenario

Los handlers de éxito por defecto son el punto de partida. Pero las apps reales necesitan manejar errores también. Aquí es donde la mayoría de los setups de MSW se detienen. No te detengas aquí.

Yo creo handler sets separados para cada escenario de error que la app necesita manejar:

// Éxito (default)
export const handlers = [...apiHandlers, ...authHandlers];

// Errores del servidor
export const errorHandlers = [
  http.get(`${BASE_URL}/items`, () => {
    return HttpResponse.json(
      { message: 'Internal server error' },
      { status: 500 }
    );
  }),
];

// No autorizado (token expirado)
export const unauthorizedHandlers = [
  http.get(`${BASE_URL}/items`, () => {
    return HttpResponse.json(
      { error: 'invalid_token', message: 'Token has expired' },
      { status: 401 }
    );
  }),
];

// Rate limiting
export const rateLimitHandlers = [
  http.post(`${BASE_URL}/auth/token`, () => {
    return HttpResponse.json(
      { error: 'too_many_requests', message: 'Try again in 60 seconds' },
      { status: 429, headers: { 'Retry-After': '60' } }
    );
  }),
];

// Timeout (nunca resuelve)
export const timeoutHandlers = [
  http.get(`${BASE_URL}/items`, async () => {
    await new Promise(resolve => setTimeout(resolve, 60000));
    return HttpResponse.json({}, { status: 408 });
  }),
];

// Offline (fallo de red)
export const offlineHandlers = [
  http.get(`${BASE_URL}/items`, () => {
    return HttpResponse.error();
  }),
];

En mi proyecto, tengo 11 handler sets:

Handler setStatusQué testea
handlers200Respuestas exitosas por defecto
errorHandlers500Manejo de errores del servidor
unauthorizedHandlers401Flujos de token expirado/inválido
forbiddenHandlers403Cuentas baneadas/suspendidas
conflictHandlers409Registro duplicado
validationErrorHandlers422Errores de validación de formularios
rateLimitHandlers429Rate limiting con Retry-After
emailNotConfirmedHandlers400Verificación de email requerida
storageErrorHandlers413/404Errores de subida/eliminación de archivos
timeoutHandlers408Simulación de timeout de red
offlineHandlersErrorFallo total de red

Cada set se exporta y se puede intercambiar por test.

💡 Tip: El handler de timeout usa await new Promise(resolve => setTimeout(resolve, 60000)) para simular una petición que nunca termina. El timeout de tu código se disparará primero, testeando el path de manejo de timeout.

Usando handlers en tests

Los handlers por defecto corren automáticamente (registrados en setupServer). Para testear escenarios de error, sobrescríbelos por test:

import { server } from '@app/test-utils/msw/server';
import { errorHandlers, unauthorizedHandlers } from '@app/test-utils/msw/handlers';

describe('API error handling', () => {
  it('shows error message on server failure', async () => {
    server.use(...errorHandlers);

    // Renderizar componente, disparar fetch, verificar UI de error
  });

  it('redirects to login on 401', async () => {
    server.use(...unauthorizedHandlers);

    // Renderizar componente, disparar fetch, verificar redirección
  });

  // No hace falta limpiar - afterEach en jest.setup resetea los handlers
});

El spread (...errorHandlers) reemplaza los handlers que matchean. Los handlers del set por defecto que no matchean siguen activos. Después del test, server.resetHandlers() restaura los defaults.

El wrapper de render personalizado

MSW funciona mejor con un store real de Redux, no uno mockeado. El punto es testear la integración completa: componente → thunk de Redux → petición HTTP → intercepción de MSW → respuesta → actualización de estado → actualización de UI.

import { configureStore, combineReducers } from '@reduxjs/toolkit';
import { render } from '@testing-library/react-native';

const rootReducer = combineReducers({
  items: itemsReducer,
  auth: authReducer,
});

type RootState = ReturnType<typeof rootReducer>;

function createTestStore(preloadedState?: Partial<RootState>) {
  return configureStore({
    reducer: rootReducer,
    preloadedState,
    middleware: getDefaultMiddleware =>
      getDefaultMiddleware({
        serializableCheck: false,
        immutableCheck: false,
      }),
  });
}

export function renderWithProviders(
  ui: React.ReactElement,
  { preloadedState, store, ...options } = {}
) {
  const createdStore = store || createTestStore(preloadedState);

  function Wrapper({ children }) {
    return (
      <Provider store={createdStore}>
        {children}
      </Provider>
    );
  }

  return {
    store: createdStore,
    ...render(ui, { wrapper: Wrapper, ...options }),
  };
}

Ahora tus tests renderizan con un store real, despachan thunks reales, y MSW maneja la red:

it('loads and displays items', async () => {
  // Los handlers por defecto devuelven respuesta exitosa
  const { getByText } = renderWithProviders(<ItemList />);

  await waitFor(() => {
    expect(getByText('Item One')).toBeTruthy();
  });
});

it('shows error state on failure', async () => {
  server.use(...errorHandlers);

  const { getByText } = renderWithProviders(<ItemList />);

  await waitFor(() => {
    expect(getByText('Something went wrong')).toBeTruthy();
  });
});

Sin mockeo manual de dispatch, selectores o fetch. Todo el stack es real excepto la red.

Overrides de handlers inline

A veces necesitas una respuesta puntual que no encaja en ningún handler set. Defínela inline:

it('handles unexpected response shape', async () => {
  server.use(
    http.get('https://api.example.com/items', () => {
      return HttpResponse.json({ unexpected: 'shape' });
    })
  );

  // Testear que el código maneja respuestas malformadas correctamente
});

Esto es útil para edge cases como JSON malformado, campos faltantes o códigos de estado inesperados que no ameritan un handler set completo.

Errores comunes

Los handlers se matchean en orden. Si dos handlers matchean la misma petición, el primero gana. Cuando llamas a server.use(...overrides), los overrides se agregan al principio, así que tienen prioridad sobre los defaults.

HttpResponse.error() simula un fallo de red, no un error HTTP. La petición nunca recibe respuesta. Úsalo para escenarios offline. Para errores HTTP (500, 401 y demás), recurre a HttpResponse.json() con un código de estado.

Si tu handler lee el body del request vía request.json(), la función tiene que ser async. Olvidarlo es una de las formas más comunes de terminar con un handler que devuelve undefined en silencio.

Las peticiones sin handler son silenciosas por defecto. Siempre usa onUnhandledRequest: 'warn' (o 'error' en CI) para que los handlers faltantes salgan a la luz. Una petición sin handler silenciosa significa que el test pasa por la razón equivocada.

La estructura de archivos completa

src/
  test-utils/
    msw/
      handlers.ts       # Todos los handler sets (éxito, error, 401, etc.)
      server.ts          # setupServer con handlers por defecto
      mockData.ts        # Datos fixture usados por los handlers
    renderWithProviders.tsx  # Render personalizado con store real + providers
    index.ts             # Barrel export

El barrel export (index.ts) permite que los tests importen utilidades comunes desde un solo lugar. Para handler sets específicos, importa directamente del archivo de handlers:

import { server, renderWithProviders } from '@app/test-utils';
import { errorHandlers, unauthorizedHandlers } from '@app/test-utils/msw/handlers';

En resumen

El setup lleva unos treinta minutos. Después de eso, cada test nuevo es más simple que el equivalente con mocks manuales. Escribes server.use(...errorHandlers) en vez de jest.fn().mockRejectedValue(new Error('Network error')). Los handlers son reutilizables en cada archivo de test. Y el test ejercita comportamiento de integración real, no comportamiento de mocks.

Los 11 handler sets de mi proyecto cubren cada path de error que la app maneja. Combinados con tests E2E escritos en Gherkin con Detox + Cucumber y mocking en runtime a nivel de Metro, los handler sets cubren desde tests unitarios hasta flujos completos de usuario. Cuando añado un nuevo endpoint de API, añado handlers una vez, y cada test que toca ese endpoint obtiene mocking correcto gratis.

Si escribir el próximo test es más difícil que saltártelo, tu infraestructura de test es el problema.

Los ejemplos de código en este post son de rn-warrendeleon, mi proyecto personal de React Native. El setup completo de MSW, los handler sets y el wrapper de render personalizado están en el repo.

Warren de Leon
Warren de Leon

Software Engineering Manager en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil