Per què MSW en comptes de mocks manuals
La majoria de projectes React Native simulen la seva capa d’API amb jest.fn(). Simules fetch o la teva instància d’Axios, defineixes què retorna, i proves contra això.
Funciona. Fins que no.
El problema: estàs verificant la interacció del teu codi amb un mock, no amb una capa HTTP. Si el teu client d’API canvia com construeix URLs, afegeix headers o gestiona reintents, el mock no detecta la regressió. (Una capa de validació de respostes en temps d’execució amb Zod tampoc s’exercitaria). El mock sempre retorna el que li has dit, independentment del que el codi realment ha enviat.
Mock Service Worker (MSW) intercepta les peticions a nivell de xarxa. El teu codi fa crides HTTP reals. MSW les captura abans que surtin del procés i retorna les teves respostes simulades. Tot el que hi ha entre el teu component i la xarxa s’exercita: el thunk de Redux, els interceptors d’Axios, la gestió d’errors, el parseig de la resposta.
Els mocks manuals reemplacen el teu codi. MSW reemplaça la xarxa. El codi s’executa exactament com ho faria en un dispositiu, fins al punt just on la petició n’hauria sortit.
Instal·lació
MSW v2 funciona a React Native a través del servidor de Node.js (per a tests de Jest). El service worker del navegador no és rellevant per a mòbil.
yarn add -D msw
Això és tot. Sense polyfills, sense canvis a la config de Metro, sense linking de mòduls natius.
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ínies. El servidor agafa els teus handlers per defecte (respostes exitoses) i intercepta les peticions que coincideixen.
Connectant-lo amb Jest
Al teu jest.setup.ts (o .js), afegeix el cicle de vida de MSW:
import { server } from './src/test-utils/msw/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
| Hook | Què fa |
|---|---|
beforeAll | Inicia el servidor abans que s’executi cap test |
afterEach | Reseteja els handlers als defaults entre tests (perquè els overrides d’un test no es filtrin) |
afterAll | Atura el servidor després que tots els tests acabin |
L’opció onUnhandledRequest: 'warn' registra un warning si el teu codi fa una petició que cap handler coincideix. Això atrapa handlers que falten aviat en comptes de deixar que els tests fallin amb errors de xarxa críptics.
Escrivint handlers
Cada handler és una funció que coincideix amb un mètode HTTP i una URL, i retorna una resposta.
Un handler bàsic per a 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 });
}),
];
Algunes coses que val la pena saber: els helpers específics per mètode (http.get, http.post i la resta) coincideixen pel verb HTTP, els paràmetres d’URL com :id se t’extreuen a params, el body de la petició arriba via await request.json(), i HttpResponse.json() retorna JSON tipat amb el codi d’estat que li passis.
Handler sets per a cada escenari
Els handlers d’èxit per defecte són el punt de partida. Però les apps reals necessiten gestionar errors també. Aquí és on la majoria de setups de MSW s’aturen. No t’aturis aquí.
Jo creo handler sets separats per a cada escenari d’error que l’app necessita gestionar:
// Èxit (default)
export const handlers = [...apiHandlers, ...authHandlers];
// Errors del servidor
export const errorHandlers = [
http.get(`${BASE_URL}/items`, () => {
return HttpResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
}),
];
// No autoritzat (token expirat)
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 (mai resol)
export const timeoutHandlers = [
http.get(`${BASE_URL}/items`, async () => {
await new Promise(resolve => setTimeout(resolve, 60000));
return HttpResponse.json({}, { status: 408 });
}),
];
// Offline (fallada de xarxa)
export const offlineHandlers = [
http.get(`${BASE_URL}/items`, () => {
return HttpResponse.error();
}),
];
Al meu projecte, tinc 11 handler sets:
| Handler set | Status | Què verifica |
|---|---|---|
handlers | 200 | Respostes exitoses per defecte |
errorHandlers | 500 | Gestió d’errors del servidor |
unauthorizedHandlers | 401 | Fluxos de token expirat/invàlid |
forbiddenHandlers | 403 | Comptes baneigs/suspesos |
conflictHandlers | 409 | Registre duplicat |
validationErrorHandlers | 422 | Errors de validació de formularis |
rateLimitHandlers | 429 | Rate limiting amb Retry-After |
emailNotConfirmedHandlers | 400 | Verificació d’email requerida |
storageErrorHandlers | 413/404 | Errors de pujada/eliminació de fitxers |
timeoutHandlers | 408 | Simulació de timeout de xarxa |
offlineHandlers | Error | Fallada total de xarxa |
Cada set s’exporta i es pot intercanviar per test.
💡 Consell: El handler de timeout usa
await new Promise(resolve => setTimeout(resolve, 60000))per simular una petició que mai acaba. El timeout del teu codi es dispararà primer, verificant el path de gestió de timeout.
Usant handlers en tests
Els handlers per defecte s’executen automàticament (registrats a setupServer). Per provar escenaris d’error, sobreescriu-los per 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);
// Renderitzar component, disparar fetch, verificar UI d'error
});
it('redirects to login on 401', async () => {
server.use(...unauthorizedHandlers);
// Renderitzar component, disparar fetch, verificar redirecció
});
// No cal netejar - afterEach a jest.setup reseteja els handlers
});
L’spread (...errorHandlers) reemplaça els handlers que coincideixen. Els handlers del set per defecte que no coincideixen segueixen actius. Després del test, server.resetHandlers() restaura els defaults.
El wrapper de render personalitzat
MSW funciona millor amb un store real de Redux, no un de simulat. El punt és provar la integració completa: component → thunk de Redux → petició HTTP → intercepció de MSW → resposta → actualització d’estat → actualització d’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 }),
};
}
Ara els teus tests renderitzen amb un store real, despatxen thunks reals, i MSW gestiona la xarxa:
it('loads and displays items', async () => {
// Els handlers per defecte retornen resposta 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();
});
});
Sense simulació manual de dispatch, selectors o fetch. Tot l’stack és real excepte la xarxa.
Overrides de handlers inline
De vegades necessites una resposta puntual que no encaixa en cap handler set. Defineix-la inline:
it('handles unexpected response shape', async () => {
server.use(
http.get('https://api.example.com/items', () => {
return HttpResponse.json({ unexpected: 'shape' });
})
);
// Verificar que el codi gestiona respostes malformades correctament
});
Això és útil per a edge cases com JSON malformat, camps que falten o codis d’estat inesperats que no mereixen un handler set complet.
Errors comuns
Els handlers es comproven en ordre. Si dos handlers coincideixen amb la mateixa petició, guanya el primer. Quan crides server.use(...overrides), els overrides s’afegeixen al principi, així que prevalen sobre els defaults.
HttpResponse.error() simula una fallada de xarxa, no un error HTTP. La petició mai rep resposta. Usa-ho per a escenaris offline. Per a errors HTTP (500, 401, etc.), tira de HttpResponse.json() amb un codi d’estat.
Si el teu handler llegeix el body de la petició via request.json(), la funció del handler ha de ser async. Oblidar-ho és una de les maneres més habituals d’acabar amb un handler que retorna undefined silenciosament.
Les peticions sense handler són silencioses per defecte. Sempre usa onUnhandledRequest: 'warn' (o 'error' en CI) perquè els handlers que falten surtin a la llum. Una petició sense handler silenciosa vol dir que el test passa per la raó equivocada.
Un error Response is not defined o TextEncoder is not defined vol dir que el fitxer de polyfills no s’està carregant. Comprova que setupFiles: ['<rootDir>/jest.polyfills.cjs'] hi és a la config de Jest, que l’extensió del fitxer és .cjs i no .ts, i que el path és correcte respecte a rootDir.
Un SyntaxError: Cannot use import statement outside a module llançat des de node_modules/msw/ vol dir que MSW no s’està transformant. Afegeix msw|until-async a la allow-list dins de transformIgnorePatterns.
Les barres finals importen: http.get('/api/items') no coincideix amb una petició a /api/items/. Coincideix exactament el que el teu codi envia, o usa un patró de path com http.get('/api/items*', ...).
Els tests passen en local i fallen en CI acostuma a ser onUnhandledRequest: 'error' atrapant una petició que no t’havies adonat que el codi feia en l’entorn de CI, sovint d’analytics o crash reporting. O bé hi afegeixes un handler, o bé treus aquestes crides en mode test.
L’estructura de fitxers completa
src/
test-utils/
msw/
handlers.ts # Tots els handler sets (èxit, error, 401, etc.)
server.ts # setupServer amb handlers per defecte
mockData.ts # Dades fixture usades pels handlers
renderWithProviders.tsx # Render personalitzat amb store real + providers
index.ts # Barrel export
El barrel export (index.ts) permet que els tests importin utilitats comunes des d’un sol lloc. Per a handler sets específics, importa directament del fitxer de handlers:
import { server, renderWithProviders } from '@app/test-utils';
import { errorHandlers, unauthorizedHandlers } from '@app/test-utils/msw/handlers';
En resum
El setup costa uns trenta minuts. A partir d’aquí, cada test nou surt més simple que l’equivalent amb mocks manuals. Escrius server.use(...errorHandlers) en comptes de jest.fn().mockRejectedValue(new Error('Network error')). Els handlers es reutilitzen a cada fitxer de test. I el que el test exercita és comportament d’integració, no comportament de mocks.
Els 11 handler sets del meu projecte cobreixen cada path d’error que l’app gestiona. Combinats amb tests E2E escrits en Gherkin amb Detox + Cucumber i mocking en temps d’execució a nivell de Metro, els handler sets cobreixen des de tests unitaris fins a fluxos complets d’usuari. Quan afegeixo un nou endpoint d’API, afegeixo handlers un cop, i cada test que toca aquell endpoint obté mocking correcte gratis.
Si escriure el pròxim test és més difícil que saltar-se’l, la teva infraestructura de test és el problema.
Els exemples de codi d’aquest post són de rn-warrendeleon, el meu projecte personal de React Native. El setup complet de MSW, els handler sets i el wrapper de render personalitzat són al repo.