miniflare: API mocking with MSW fails after upgrade to miniflare 2.2.0

Hey @mrbbot, thanks for all the work on miniflare, it’s been a game-changer! With miniflare 1, I’ve been using mock service worker to mock API responses. When attempting to upgrade to miniflare 2.2.0, my tests failed. I’ve no idea where to start looking for a solution, so I’ve put up a fairly minimal demo, hoping that’ll make it easier to pinpoint the cause. HEAD is using miniflare 2.2.0, while the miniflare-1.4.1 branch shows what it looks like when it’s working. Running npm test should show failing tests for HEAD and passing for the latter.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 19 (2 by maintainers)

Most upvoted comments

Hey @SupremeTechnopriest! 👋 Unfortunately, you won’t be able to use setGlobalDispatcher inside jest-environment-miniflare. Miniflare’s undici version is imported outside the Jest environment in Node. Jest and Node don’t share a module cache, so trying to import undici inside Jest will always give you a different version.

Currently, I think the best option for jest-environment-miniflare is to use Jest’s function mocks to mock the global fetch function. Something like… (untested 😅)

const fetchSpy = jest.spyOn(globalThis, "fetch");
fetchSpy.mockImplementation((input, init) => {
  const req = new Request(input, init);
  const url = new URL(req.url);
  if (req.method === "GET" && url.pathname === "/") {
    return new Response("bar");
  }
  return new Response(null, { status: 404 });
});

...

jest.restoreAllMocks();

I’d definitiely like to start a discussion on this though. Maybe a global API like getMiniflareMockAgent() that returned a correctly-setup MockAgent?

@SupremeTechnopriest and @LukePammant, I can confirm that mocking the globalThis.fetch doesn’t work with miniflare.dispatchFetch. We have some fetch() calls to some external APIs inside our worker, we wanted to test those calls, but spying fetch was not possible. We used this library to workaround. We start a local HTTP server and use this server instead of external APIs.

I was hoping that perhaps this issue had been resolved with the release of 2.4.0 yesterday, but it has been postponed to 2.5.0. I am very much looking forward to the next release 👀

This was super helpful to me, as jest-fetch-mock doesn’t return actual responses!

Here’s the wrapper I’m using now:

// @test/helpers/MockAgent.ts

type RouteMatcher = (
  input: Request | string,
  init?: RequestInit | Request,
) => boolean

type Route = {
  matcher: RouteMatcher
  response: Response
}

/**
 * Returns a handler function composed of passed Handlers,
 * falling back to a 404 Response on no match.
 *
 * @see https://github.com/cloudflare/miniflare/issues/162#issuecomment-1059549646
 */
export function createRequestHandlers(routes: Route[] = []) {
  return async function handler(
    input: Request | string,
    init?: RequestInit | Request,
  ) {
    for (const { matcher, response } of routes) {
      if (matcher(input, init)) return response
    }

    return new Response('Not Found', { status: 404 })
  }
}

type CreateMatcherOptions = {
  method: string
}
/** Returns a RouteMatcher which evaluates matches based on `pathname` & `method` */
export function createMatcher(
  pathname: string,
  { method }: CreateMatcherOptions = { method: 'GET' },
): RouteMatcher {
  return function (input: Request | string, init?: RequestInit | Request) {
    const req = new Request(input, init)
    const url = new URL(req.url)
    return method === req.method && pathname === url.pathname
  }
}

export const get = (pathname: string) => createMatcher(pathname)
export const post = (pathname: string) =>
  createMatcher(pathname, { method: 'POST' })

/**
 * Mocks global fetch and returns a fetchSpy.
 *
 * ```ts
  // example GET matching on pathname /
  const fetchSpy = mockFetch('/', new Response('mocked'))

  // example POST /bar (using lower level matcher)
  const fetchSpy = mockFetch((input, init) => {
    const req = new Request(input, init)
    const url = new URL(req.url)
    return req.method === 'POST' && url.pathname === '/bar'
  }, new Response('mocked POST'))
 * ```
 */
export function mockFetch(
  pathnameOrMatcher: string | RouteMatcher,
  response: Response,
) {
  const fetchSpy = jest.spyOn(globalThis, 'fetch')
  const requestHandlers = createRequestHandlers([
    {
      matcher:
        typeof pathnameOrMatcher === 'string'
          ? get(pathnameOrMatcher)
          : pathnameOrMatcher,
      response,
    },
  ])
  fetchSpy.mockImplementation(requestHandlers)
  return fetchSpy
}

import { jest } from '@jest/globals'
import {
  basicRequestHandler,
  createRequestHandlers,
  get,
  mockFetch,
} from '@test/helpers/MockAgent'

afterEach(() => {
  jest.restoreAllMocks()
})

test('should provide mockFetch', async () => {
  const _fetchSpy = mockFetch('/', new Response('mocked'))

  const req = new Request('http://localhost/')
  const res = await fetch(req)
  expect(res.status).toBe(200)
  expect(await res.text()).toBe('mocked')
})

test('should mock different routes', async () => {
  let mockBarOnce = true

  const requestHandlers = createRequestHandlers([
    {
      matcher: get('/foo'),
      response: new Response('foo'),
    },
    {
      matcher: (input, init) => {
        const req = new Request(input, init)
        const url = new URL(req.url)
        const matches = req.method === 'GET' && url.pathname === '/bar'
        if (matches && mockBarOnce) {
          mockBarOnce = false
          return true
        }
        return false
      },
      response: new Response('bar'),
    },
  ])

  const fetchSpy = jest.spyOn(globalThis, 'fetch')
  fetchSpy.mockImplementation(requestHandlers)

  let req = new Request('http://localhost/')
  let res = await fetch(req)
  expect(res.status).toBe(404)

  req = new Request('http://localhost/foo')
  res = await fetch(req)
  expect(res.status).toBe(200)
  expect(await res.text()).toBe('foo')

  // mock GET /bar only once
  req = new Request('http://localhost/bar')
  res = await fetch(req)
  expect(res.status).toBe(200)
  expect(await res.text()).toBe('bar')

  // second call to GET /bar passes through
  req = new Request('http://localhost/bar')
  res = await fetch(req)
  expect(res.status).toBe(404)

  expect(fetchSpy.mock.calls).toHaveLength(4)
})

(updated)

Works for me if I call the handler directly, but not with mf.dispatchFetch:

Works:

// src/index.ts
export async function handleRequest(request: Request, env: Bindings) {
  const res = await fetch("http://not-a-real-domain.foo");
  return res;
}
import { handleRequest } from "@/index";
// ...

test("should provide mockFetch", async () => {
  const _fetchSpy = mockFetch(
    "http://not-a-real-domain.foo",
    new Response("mocked")
  );

  const env = getMiniflareBindings();
  const req = new Request("http://localhost/");
  const res = await handleRequest(req, env);

  expect(_fetchSpy).toHaveBeenCalledTimes(1);
  expect(res.status).toBe(200);
  expect(await res.text()).toBe("mocked");
});

It should mock the fetch call for your module code as well. Are you seeing something different?

Wrote a little warpper while we sort out the mock global.

export function MockFetch (base: string) {
  const mocks: IFetchMock[] = []
  const fetchSpy = jest.spyOn(globalThis, 'fetch')
  fetchSpy.mockImplementation(async (input: string | Request, init?: Request | RequestInit | undefined): Promise<Response> => {
    const request = new Request(input, init)

    for (const mock of mocks) {
      if (`${base}${mock.path}` === request.url && request.method === mock.method) {
        const resInit = {
          status: mock.status,
          statusText: mock.statusText,
          headers: {}
        }
        if (mock.headers) resInit.headers = mock.headers
        return new Response(mock.body, resInit)
      }
    }
    return new Response(null, { status: 404, statusText: 'Not Found' })
  })

  const intercept = (mock: IFetchMock) => {
    mocks.push(mock)
  }

  const restore = () => {
    mocks.length = 0
    jest.restoreAllMocks()
  }

  return { intercept, restore }
}
const { intercept, restore } = MockFetch('http://foo.com')

intercept({
  path: '/',
  method: 'GET',
  status: 200,
  statusText: 'OK',
  body: 'bar',
  headers: {
    'X-Foo': 'bar'
  }
})

afterAll(restore)

test('Mock', async () => {
  const request = new Request('http://foo.com/')
  const res = await fetch(request)
  const text = await res?.text()
  expect(text).toBe('bar')
  expect(res.headers.get('x-foo')).toBe('bar')
})

Thanks! That’ll get me back on track, probably using the built-in mocking feature of undici for now.