CodeWorlds
Powrót do kolekcji
Przewodnik13 min czytaniaZespół CodeWorlds

MSW, atrapy sieci dla testów i przeglądarki

MSW przechwytuje zapytania na poziomie sieci, więc te same atrapy działają w Node i w przeglądarce. Wersja 2.15.0, plik workera i realne koszty.

MSW, atrapy sieci dla testów i przeglądarki

MSW przechwytuje zapytania HTTP na poziomie warstwy sieciowej, zamiast podmieniać funkcję fetch czy moduł klienta w kodzie aplikacji. Ten sam zestaw handlerów obsługuje testy w Node, aplikację uruchomioną w przeglądarce i historyjki w Storybooku. Bieżąca wersja to 2.15.0 z 8 lipca 2026 roku, licencja MIT, repozytorium mswjs/msw.

Na czym polega przechwycenie

Różnica wobec podmieniania modułów jest mechaniczna, nie estetyczna. Kiedy w teście robisz vi.mock('./api-client') albo podstawiasz własną funkcję pod globalThis.fetch, testujesz kod, który już nie jest tym samym kodem. Warstwa, którą podmieniłeś, przestaje istnieć, razem z jej obsługą nagłówków, ponowieniami, serializacją ciała zapytania i interpretacją kodów odpowiedzi. Aplikacja wie, że jest testowana, bo dostała inny obiekt niż w produkcji.

MSW schodzi piętro niżej. W przeglądarce rejestruje Service Workera, który przechwytuje zdarzenie fetch po stronie przeglądarki i przekazuje zapytanie z powrotem na stronę, gdzie uruchamiane są Twoje handlery, a wynik wraca jako zwykła odpowiedź HTTP. W Node robi to samo przez pakiet @mswjs/interceptors, który obudowuje wbudowany moduł http, XMLHttpRequest oraz globalny fetch. W obu przypadkach kod aplikacji jest nietknięty: żadnego wstrzykniętego klienta, żadnego warunku if (process.env.MOCK), żadnej ścieżki kodu, która istnieje wyłącznie w testach.

Praktyczna konsekwencja jest taka, że testujesz swojego prawdziwego klienta HTTP. Jeśli używasz axiosa z przechwytywaczem dokładającym token, on się uruchomi. Jeśli TanStack Query ponawia zapytanie po odpowiedzi 500, ponowienie faktycznie poleci i trafi w handler po raz drugi. To zaleta i jednocześnie pułapka, bo testy przestają być tak jednoznacznie deterministyczne jak przy podmienionej funkcji zwracającej stałą wartość.

Czego MSW nie robi, też trzeba powiedzieć wprost. Nie sprawdza, czy Twoje atrapy odpowiadają temu, co naprawdę zwraca serwer. Handlery to Twoje wyobrażenie o API, a wyobrażenie się starzeje. Zestaw zielonych testów opartych na MSW jest zgodny z kontraktem sprzed pół roku, jeśli nikt nie zaktualizował handlerów. Do wychwycenia tego potrzebujesz testów kontraktowych albo generowania atrap ze schematu OpenAPI, a to osobne narzędzia.

Wersja, licencja i stan projektu

Wersja 2.15.0 ukazała się 8 lipca 2026 roku, a poprzednia, 2.14.7, dzień wcześniej. Repozytorium ma około 18,1 tysiąca gwiazdek, 617 rozgałęzień i 41 otwartych zgłoszeń, nie jest zarchiwizowane, a ostatnia zmiana w gałęzi głównej pochodzi z 24 lipca 2026 roku. W rejestrze npm figuruje jeden opiekun pakietu, kettanaito, i to jest najpoważniejsze ryzyko organizacyjne przy tej bibliotece. Nie ma płatnego wariantu ani umowy wsparcia, finansowanie idzie przez GitHub Sponsors, a projekt stoi na jednej osobie i garstce współtwórców.

Licencja jest rzadkim przypadkiem pełnej zgodności trzech źródeł. Plik LICENSE.md w repozytorium zawiera tekst MIT z prawami autorskimi Artema Zakharchenki. Pole license w rejestrze npm ma wartość MIT. Opublikowana paczka, 1,24 MB i 670 plików, zawiera ten sam plik LICENSE.md w katalogu głównym. Zależność @mswjs/interceptors, na której wszystko stoi po stronie Node, także jest na MIT.

Jest jednak drobiazg, który wychodzi dopiero przy audycie własnego repozytorium. Plik mockServiceWorker.js, który MSW kopiuje do Twojego katalogu publicznego i który potem trafia do Twojego repozytorium, nie ma w nagłówku żadnej informacji o licencji. Ma tylko komentarz z nazwą projektu, odnośnik do GitHuba i prośbę, żeby go nie modyfikować. Skaner licencji przechodzący po Twoim kodzie zobaczy zatem kilkusetlinijkowy plik obcego autorstwa bez atrybucji. Jeśli w firmie generujesz zestawienie składników oprogramowania, dopisz go ręcznie albo wyklucz ze skanowania świadomie, a nie przez przeoczenie.

Waga zależności jest realna. Pakiet deklaruje osiemnaście zależności produkcyjnych, w tym graphql w wersji ^16.13.2, yargs, tough-cookie oraz @inquirer/confirm. Pakiet graphql wyląduje w Twoim katalogu node_modules nawet wtedy, gdy nigdy nie tworzysz atrapy zapytania GraphQL. Pole engines wymaga Node w wersji co najmniej 18, a typescript w wersji >= 4.8.x jest zależnością towarzyszącą oznaczoną jako opcjonalna.

W wersji 2.15.0 widać też sygnały zbliżającej się przebudowy interfejsu. Opcja waitUntilReady jest oznaczona jako przestarzała, typ LifeCycleEventsMap ustępuje miejsca HttpNetworkFrameEventMap, a klasa SetupServerCommonApi jest oznaczona jako przestarzała na rzecz interfejsu defineNetwork dostępnego pod ścieżką eksportu msw/experimental. Biblioteka jest stabilna w codziennym użyciu, ale planując większe wdrożenie, licz się z pracą migracyjną w kolejnej głównej wersji.

Instalacja i plik workera

Instalacja to dwa polecenia, z których drugie jest tym, o którym wszyscy zapominają.

Code
Bash
npm install --save-dev msw

# kopiuje mockServiceWorker.js do wskazanego katalogu publicznego
npx msw init ./public --save

Polecenie init przyjmuje ścieżkę katalogu oraz dwie opcje: --save, która zapisuje tę ścieżkę w Twoim package.json, i --cwd, która wskazuje katalog projektu, gdy polecenie uruchamiasz z innego miejsca. Efektem --save jest wpis, który wygląda tak.

Code
JSON
{
  "name": "moja-aplikacja",
  "msw": {
    "workerDirectory": ["public"]
  }
}

Ten wpis nie jest kosmetyczny. Pakiet msw ma skrypt postinstall, który czyta package.json z katalogu wskazanego przez zmienną INIT_CWD, sprawdza obecność klucza msw.workerDirectory i jeśli go znajdzie, uruchamia ponownie polecenie init, żeby odświeżyć plik workera po aktualizacji biblioteki. Bez tego wpisu skrypt kończy się natychmiast i nic nie robi.

Stąd bierze się najczęstsza usterka na serwerze budującym. Instalacja z flagą --ignore-scripts, częsta w utwardzonych potokach ciągłej integracji, pomija ten skrypt, więc w katalogu publicznym zostaje worker ze starej wersji. Biblioteka wykrywa to sama: w pliku workera siedzi stała INTEGRITY_CHECKSUM, porównywana z wartością wbudowaną w bibliotekę, a przy rozbieżności w konsoli pojawia się ostrzeżenie z instrukcją ponownego uruchomienia init.

Koszt tego rozwiązania jest dokładnie taki, jak wygląda. W katalogu publicznym leży dodatkowy plik serwowany pod adresem /mockServiceWorker.js, którego nie wolno modyfikować i który trzeba trzymać w repozytorium. Service Worker obowiązuje w zakresie wyznaczonym przez katalog, z którego jest serwowany, więc aplikacja hostowana pod podścieżką wymaga podania własnego adresu w opcji serviceWorker.url. Środowisko testowe w Node nie używa tego pliku w ogóle, co oznacza dwie osobne ścieżki konfiguracji dla jednego zestawu handlerów.

Handlery, czyli sedno konfiguracji

Handlery są wspólne dla wszystkich środowisk i trzymasz je w jednym pliku. Poniżej zestaw korzystający z realnych nazw z interfejsu wersji 2.15.0.

TSsrc/mocks/handlers.ts
TypeScript
// src/mocks/handlers.ts
import { http, HttpResponse, graphql, delay, passthrough } from 'msw'

export const handlers = [
  http.get('/api/projects/:projectId', ({ params, cookies, request }) => {
    if (!cookies.sessionToken) {
      return new HttpResponse(null, { status: 401 })
    }

    return HttpResponse.json({
      id: params.projectId,
      name: 'Migracja katalogu',
      ownerId: 'user-42',
      updatedAt: '2026-08-19T09:12:00.000Z'
    })
  }),

  http.post('/api/projects', async ({ request }) => {
    const payload = (await request.json()) as { name: string }

    await delay(120)

    return HttpResponse.json({ id: 'project-77', name: payload.name }, { status: 201 })
  }),

  http.get('/api/reports/export', () => HttpResponse.error(), { once: true }),

  graphql.query('ListMembers', () =>
    HttpResponse.json({
      data: { members: [{ id: 'user-42', role: 'OWNER' }] }
    })
  ),

  http.get('https://cdn.example.com/*', () => passthrough())
]

Kilka rzeczy w tym kodzie działa inaczej, niż podpowiada intuicja. Resolwer dostaje obiekt z polami request, requestId, params i cookies, przy czym params typuje się na podstawie wzorca ścieżki, a cookies to zwykły słownik napisów. Zwracasz standardowy obiekt Response, a klasa HttpResponse to tylko wygodne opakowanie z metodami json, text, xml, html, arrayBuffer, formData oraz error.

Opcja { once: true } sprawia, że handler odpowie tylko raz, a kolejne zapytanie przejdzie do następnego pasującego handlera. To sposób na testowanie ponowień: pierwsza próba pada, druga wraca poprawnie. Funkcja delay przyjmuje liczbę milisekund albo jeden z trybów real i infinite, gdzie ten drugi nigdy nie odpowiada i służy do sprawdzania stanów ładowania. Funkcja passthrough przepuszcza zapytanie do prawdziwego adresata, co przydaje się przy zasobach statycznych i zewnętrznych domenach.

Dopasowanie ścieżek stoi na pakiecie path-to-regexp w wersji 6, więc :projectId i gwiazdka działają tak, jak w typowym routerze. Ścieżka względna dopasowuje się względem bieżącego adresu strony, adres bezwzględny dopasowuje się dosłownie. Poza http i graphql biblioteka ma jeszcze ws dla WebSocketów i sse dla zdarzeń wysyłanych przez serwer.

Node, Vitest i granica testu

W testach uruchamianych w Node używasz setupServer z podścieżki msw/node. Nazwa myli, bo nic tu nie nasłuchuje na porcie, to po prostu przełącznik przechwytywania.

TSsrc/mocks/node.ts
TypeScript
// src/mocks/node.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
TSvitest.setup.ts
TypeScript
// vitest.setup.ts
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './src/mocks/node'

beforeAll(() => {
  server.listen({ onUnhandledRequest: 'error' })
})

afterEach(() => {
  server.resetHandlers()
})

afterAll(() => {
  server.close()
})

Ustawienie onUnhandledRequest na error jest tą jedną decyzją, która najbardziej podnosi wartość takiego zestawu testów. Dopuszczalne wartości to bypass, warn, error oraz własna funkcja zwrotna, a domyślną jest warn. Przy warn zapytanie, którego nie obsłużył żaden handler, poleci do prawdziwej sieci, a test przejdzie albo padnie z niejasnego powodu w zależności od tego, czy akurat jest internet. Przy error dowiadujesz się od razu, że czegoś nie zamockowałeś.

Do nadpisania odpowiedzi w pojedynczym teście służy server.use, a server.resetHandlers w afterEach przywraca stan początkowy. Przy testach uruchamianych równolegle w jednym procesie samo resetHandlers nie wystarcza, bo dwa testy potrafią sobie nawzajem podmienić handlery. Od tego jest server.boundary, które opakowuje funkcję i ogranicza zasięg zmian sieciowych do jej wywołania.

Code
TypeScript
import { expect, test } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from './src/mocks/node'

test(
  'pokazuje komunikat, gdy lista projektów jest niedostepna',
  server.boundary(async () => {
    server.use(
      http.get('/api/projects/:projectId', () => new HttpResponse(null, { status: 503 }))
    )

    server.events.on('request:unhandled', ({ request }) => {
      console.warn('brak handlera dla', request.url)
    })

    const response = await fetch('/api/projects/project-77')
    expect(response.status).toBe(503)
  })
)

Emiter server.events obsługuje zdarzenia request:start, request:match, request:unhandled, request:end, response:mocked oraz response:bypass. Przy diagnozowaniu testu, który zachowuje się dziwnie, podpięcie się pod request:unhandled wskazuje winowajcę szybciej niż czytanie logów aplikacji. Konfiguracja dla Vitest sprowadza się do wskazania pliku ustawień w polu setupFiles, a dla projektów w TypeScript typy jadą razem z pakietem, bez osobnej paczki z deklaracjami.

Przeglądarka, Storybook i React Native

W przeglądarce zamiast setupServer używasz setupWorker z podścieżki msw/browser, a start jest asynchroniczny, bo rejestracja Service Workera trwa.

TSsrc/mocks/browser.ts
TypeScript
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)

// src/main.tsx
async function enableMocking() {
  if (process.env.NODE_ENV !== 'development') {
    return
  }

  const { worker } = await import('./mocks/browser')

  return worker.start({
    onUnhandledRequest: 'bypass',
    quiet: false,
    serviceWorker: {
      url: '/mockServiceWorker.js'
    }
  })
}

enableMocking().then(() => {
  renderApplication()
})

Warunek na środowisko i dynamiczny import razem sprawiają, że MSW nie trafi do produkcyjnej paczki. To istotne, bo setupWorker uruchomiony przypadkiem na produkcji przechwyci ruch użytkownikom. Opcja quiet wycisza logowanie przechwyconych zapytań w konsoli, a findWorker pozwala wskazać własną funkcję wyszukującą workera wśród zarejestrowanych, gdy aplikacja ma już swojego. Wywołanie worker.start zwraca obietnicę, którą trzeba poczekać przed renderem, inaczej pierwsze zapytania aplikacji wyjdą, zanim worker zdąży się uaktywnić.

Ta sama para plików obsługuje Storybook, gdzie worker.start wywołuje się raz w konfiguracji podglądu, a poszczególne historyjki nadpisują odpowiedzi przez worker.use. Dzięki temu komponent React pokazywany w katalogu komponentów i ten sam komponent w teście jednostkowym dostają identyczne dane, bez dwóch zestawów przykładowych obiektów, które rozjeżdżają się po kilku tygodniach.

Dla React Native jest osobna podścieżka msw/native, eksportująca setupServer przystosowany do środowiska bez Service Workera. Pakiet udostępnia też ścieżki msw/core/http, msw/core/graphql i msw/core/ws, przydatne, gdy budujesz własną warstwę pomocniczą i nie chcesz ciągnąć całego korzenia pakietu.

MSW kontra alternatywy

NarzędzieWarstwa przechwyceniaGdzie działaTen sam zestaw atrap gdzie indziej
MSWService Worker w przeglądarce, interceptory modułów w Nodetesty w Node, przeglądarka, Storybook, React Nativetak, jeden plik handlerów
nockwbudowany moduł http w Nodewyłącznie Nodenie, w przeglądarce nie działa
page.route w Playwrightkontekst przeglądarki sterowany przez narzędziewyłącznie testy Playwrightnie
cy.intercept w Cypresswarstwa pośrednicząca przed przeglądarkąwyłącznie testy Cypressnie
podmiana globalThis.fetchpojedyncza funkcja globalnawszędzie, gdzie kod woła fetch bezpośrednioczęściowo, omija klientów HTTP
lokalny serwer atrapaprawdziwy port TCPwszędzie, po zmianie adresu bazowegotak, kosztem procesu i konfiguracji

Wybór nie jest zerojedynkowy i te narzędzia się nie wykluczają. W projekcie z testami jednostkowymi w Vitest, katalogiem komponentów w Storybooku i testami przeglądarkowymi MSW spina pierwsze dwa środowiska jednym zestawem handlerów, a testy pełnej ścieżki użytkownika i tak lepiej puszczać na prawdziwym backendzie. Jeśli piszesz wyłącznie backend w Node i nigdy nie mockujesz nic w przeglądarce, nock jest lżejszy i nie wymaga pliku w katalogu publicznym.

Najmocniejszy argument za MSW pojawia się wtedy, gdy ten sam zestaw atrap zaczyna obsługiwać kilka odbiorców. Jedno źródło prawdy o tym, co zwraca API, zamiast trzech rozjeżdżających się kopii w testach, w Storybooku i w trybie pracy bez backendu.

Typowe błędy

Pierwszy to niezaktualizowany plik workera. Po podniesieniu wersji biblioteki w katalogu publicznym zostaje stary mockServiceWorker.js, a przy instalacji z pominięciem skryptów nie odświeży się sam. Objawem jest ostrzeżenie o niezgodności sumy kontrolnej albo dziwne zachowanie części zapytań. Lekarstwem jest ponowne npx msw init ./public --save.

Drugi to pozostawienie domyślnego onUnhandledRequest. Domyślną wartością jest warn, więc niezamockowane zapytanie idzie do prawdziwej sieci, a test albo jest wolny, albo pada zależnie od pory dnia. W testach ustawiaj error, w przeglądarce podczas pracy zwykle bypass.

Trzeci to brak oczekiwania na worker.start. Metoda zwraca obietnicę, a Service Worker potrzebuje chwili na aktywację. Renderowanie aplikacji przed jej rozwiązaniem daje kilka pierwszych zapytań, które przelatują obok atrap i kończą się błędem 404 z serwera deweloperskiego.

Czwarty to mieszanie zasięgu handlerów między testami. Bez server.resetHandlers w afterEach nadpisanie z jednego testu zostaje na kolejne, a przy zrównoleglonych testach potrzebne jest jeszcze server.boundary, bo sam reset nie rozdziela stanów.

Piąty to traktowanie MSW jako dowodu zgodności z API. Zielone testy mówią tylko tyle, że kod działa z Twoim wyobrażeniem odpowiedzi serwera. Odpowiedzi generowane ze schematu albo testy kontraktowe to osobne zabezpieczenie, którego ta biblioteka nie zastępuje.

Szósty to zapominanie o zasięgu Service Workera przy aplikacji serwowanej z podścieżki. Worker obsługuje tylko ścieżki poniżej katalogu, z którego został pobrany, więc przy aplikacji pod adresem z prefiksem trzeba podać poprawny serviceWorker.url, inaczej rejestracja się uda, a przechwytywanie nie zadziała.

FAQ

Czy MSW nadaje się do użycia na produkcji?

Nie jest do tego przeznaczone i uruchomienie go na produkcji oznacza przechwytywanie ruchu prawdziwych użytkowników. Kod startujący workera trzymaj za warunkiem na środowisko i za dynamicznym importem, żeby biblioteka nie weszła do paczki produkcyjnej.

Czym to się różni od podmiany fetch w Vitest?

Podmiana fetch usuwa z testu całą warstwę klienta HTTP razem z jej logiką. MSW zostawia ją nietkniętą i podstawia odpowiedź dopiero na poziomie sieci, więc w teście uruchamiają się Twoje przechwytywacze, ponowienia i obsługa nagłówków. Ta sama definicja atrapy działa potem w przeglądarce.

Czy plik workera trzeba trzymać w repozytorium?

Tak, mockServiceWorker.js musi leżeć w katalogu publicznym i być serwowany razem z aplikacją, więc trafia do repozytorium. Odświeża go polecenie init, uruchamiane automatycznie po instalacji, jeśli w package.json jest klucz msw.workerDirectory.

Czy MSW działa z axios i Apollo Client?

Tak, bo przechwycenie następuje poniżej tych bibliotek. Axios w przeglądarce korzysta z XMLHttpRequest, w Node z modułu http, a Apollo Client z fetch, i każda z tych dróg jest objęta przechwytywaniem. Zapytania GraphQL można też opisywać handlerami graphql.query i graphql.mutation zamiast dopasowywać je po adresie.

Co zrobić, gdy zapytanie nie trafia w żaden handler?

Ustaw onUnhandledRequest na error i podepnij nasłuch zdarzenia request:unhandled przez server.events.on, żeby zobaczyć dokładny adres. Najczęstsze przyczyny to ścieżka względna zamiast bezwzględnej przy zapytaniu na inną domenę oraz literówka w nazwie parametru ścieżki.

Pełny opis interfejsu znajdziesz w dokumentacji MSW, a kod źródłowy i zgłoszenia w repozytorium na GitHubie.

Czytaj dalej

Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie