It started innocently enough. "I'll just refactor these fetch calls to use Axios," I thought, "What could possibly go wrong?" As it turns out, quite a bit - specifically, all my carefully crafted fetch mocks suddenly becoming about as useful as a chocolate teapot.
Rather than rebuilding all my mocks for Axios, I decided to take this opportunity to modernize my approach. Enter Mock Service Worker (MSW).
Previously, my tests looked something like this:
const mockFetch = vi.fn(); global.fetch = mockFetch; describe("API functions", () => { beforeEach(() => { mockFetch.mockReset(); }); test("fetchTrips - should fetch trips successfully", async () => { const mockTrips = [{ id: 1, name: "Trip to Paris" }]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockTrips, }); const trips = await fetchTrips(mockSupabase); expect(trips).toEqual(mockTrips); }); });
It worked, but it wasn't exactly elegant. Each test required manual mock setup, the mocks were brittle, and they didn't really represent how my API behaved in the real world. I was testing implementation details rather than actual behaviour.
Mock Service Worker (MSW) takes a fundamentally different approach to API mocking. Instead of mocking function calls, it intercepts actual network requests at the network level. This is huge for a few reasons:
Here's how those same tests look with MSW:
// Your API handler definition http.get(`${BASE_URL}/trips`, () => { return HttpResponse.json([ { id: "1", location: "Trip 1", days: 5, startDate: "2023-06-01" }, { id: "2", location: "Trip 2", days: 7, startDate: "2023-07-15" }, ]); }); // Your test - notice how much cleaner it is test("fetchTrips - should fetch trips successfully", async () => { const trips = await fetchTrips(); expect(trips).toEqual([ { id: "1", location: "Trip 1", days: 5, startDate: "2023-06-01" }, { id: "2", location: "Trip 2", days: 7, startDate: "2023-07-15" }, ]); });
No more manual mock setup for each test - the MSW handler takes care of it all. Plus, these handlers can be reused across many tests, reducing duplication and making your tests more maintainable.
Setting up MSW was surprisingly straightforward, which immediately made me suspicious. Nothing in testing is ever this easy...
beforeAll(() => { server.listen({ onUnhandledRequest: "bypass" }); }); afterEach(() => { server.resetHandlers(); cleanup(); }); afterAll(() => { server.close(); });
Then creating handlers that actually looked like my API:
export const handlers = [ http.get(`${BASE_URL}/trips`, () => { return HttpResponse.json([ { id: "1", location: "Trip 1", days: 5, startDate: "2023-06-01" }, { id: "2", location: "Trip 2", days: 7, startDate: "2023-07-15" }, ]); }), ];
My first attempt at error handling was... well, let's say it was optimistic:
export const errorHandlers = [ http.get(`${BASE_URL}/trips/999`, () => { return new HttpResponse(null, { status: 404 }); }), ];
The problem? The more general /trips/:id handler was catching everything first. It was like having a catch-all route in your Express app before your specific routes - rookie mistake.
After some head-scratching and test failures, I realized the better approach was handling errors within the routes themselves:
const mockFetch = vi.fn(); global.fetch = mockFetch; describe("API functions", () => { beforeEach(() => { mockFetch.mockReset(); }); test("fetchTrips - should fetch trips successfully", async () => { const mockTrips = [{ id: 1, name: "Trip to Paris" }]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockTrips, }); const trips = await fetchTrips(mockSupabase); expect(trips).toEqual(mockTrips); }); });
This pattern emerged: instead of separate error handlers, I could handle both success and error cases in the same place, just like a real API would. It was one of those "aha!" moments where testing actually pushes you toward better design.
The final setup is more maintainable, more realistic, and actually helpful in catching real issues. Gone are the days of:
// Your API handler definition http.get(`${BASE_URL}/trips`, () => { return HttpResponse.json([ { id: "1", location: "Trip 1", days: 5, startDate: "2023-06-01" }, { id: "2", location: "Trip 2", days: 7, startDate: "2023-07-15" }, ]); }); // Your test - notice how much cleaner it is test("fetchTrips - should fetch trips successfully", async () => { const trips = await fetchTrips(); expect(trips).toEqual([ { id: "1", location: "Trip 1", days: 5, startDate: "2023-06-01" }, { id: "2", location: "Trip 2", days: 7, startDate: "2023-07-15" }, ]); });
Instead, I have proper API mocks that:
Looking forward, I'm excited about:
Sometimes the best improvements come from being forced to change. What started as a simple Axios refactor ended up leading to a much better testing architecture. And isn't that what refactoring is all about?
This article was originally published on my blog. Follow me there for more content about full-stack development, testing, and API design.
The above is the detailed content of From Fetch Mocks to MSW: A Testing Journey. For more information, please follow other related articles on the PHP Chinese website!