Skip to main content

Introducing @fetch-mock/core

· 6 min read
fetch-mock maintainer

@fetch-mock/core is a new library for building up a mock fetch implementation, using (more or less) the same chainable API that you're familiar with from fetch-mock. It reimplements a lot of the fetch-mock API, but with some significant changes

What's gone from fetch-mock

Some features are removed from the @fetch-mock suite for good, while some have been removed from @fetch-mock/core, but will reappear in some form in wrappers of @fetch-mock/core that target specific toolchains.

Gone for good

Inferred route names

This was probably the worst design decision in fetch-mock. Inspecting calls that had been handled by a particular route relied on being able to pick out that route from the list of all routes. Early on fetch-mock mostly supported string matchers, and coercing a string into a string that could be used as a route name is trivial. However, later addition of Regex, Function, header matchers etc meant that this broke down, leading to a confusing experience when trying to retrieve the right fetch calls. In an earlier version I added the ability to explicitly name a route, and in fetch-mock@10 I also added a simpler API for specifying these names. With all that done, it's a lot easier to name, and later refer to, a given route without having to infer any names.

overwriteRoutes

Due to the inferred naming behaviour described above, fetch-mock was by default very fussy about allowing two different routes which had the same inferred name to be added. Now that inferred routes are gone, there is no longer any need for the user to specify whether or not to overwrite a previously added route; new routes are always added successfully without any reference to the prevoiuosly added routes.

Debug logging/warnOnFallback

warnOnFallback turned on logging if a call was handled by the route added by .catch(), or fell back to the network. And the application code was also peppered with lots of uses of the debug library. Taken together these added a lot of bulk to the code, making it harder to read and work with, and I've seen no evidence that users actually find it useful. It's possible I may add some debug logging back in future based on user feedback, but at this point I have no intention to.

.lastUrl(), .lastOptions(), .lastResponse()

These methods gave access to specific parts of teh last fetch call. With the introduction of the new CallLog format for logging calls these seem unnecessary e.g. .lastUrl() can be replaced by .lastCall().url.

.sandbox()

This was another very bad design decision that I'm very happy to get rid of. fetch-mock had no problem mocking global fetch, but in order to mock node-fetch I needed to provide a way to expose fetch-mock's mock of fetch, fetchHandler. I chose the way-too-clever approach of extending fetchHandler with the fetchMock object, so that my implementation of fetch also had methods .mock(), .catch() etc attached to it. This was the number one cause of conflict with Jest.

@fetch-mock/core takes the far more sensible approach of keeping the function and the fetchMock object separate, so that instead of

jest.mock(global, 'fetch', fetchMock.sandbox());
fetch.mock('http://my.site', 200);

you now have

jest.mock(global, 'fetch', fetchMock.fetchHandler);
fetchMock.mock('http://my.site', 200);

which keeps fetch-mock's methods much further away from any other library's workings.

.getAny(), .postAny(), .putAny(), .deleteAny(), .headAny(), .patchAny(), .getAnyOnce(), .postAnyOnce(), .putAnyOnce(), .deleteAnyOnce(), .headAnyOnce(), .patchAnyOnce()

While .getOnce() etc feel very useful, the any and anyOnce variants added a lot of repetition to the code and types, and don't actually add much value.

.___AnyOnce(response, options)

Creates a route that responds to any single request using a particular http method.

Gone, but back soon

The following features will return in other libraries that wrap @fetch-mock/core for different environments.

.mock()

@fetch-mock/core does not implement any functionality for replacing global fetch or a local fetch implementation (such as node-fetch) with a mock implementation.

.spy()/fallbackToNetwork

As @fetch-mock/core does not do anything to replace the native fetch implementation, these features - which pass through the fetch-mock implementation and go straight to the native implementation - are also concersn that will be added to wrappers.

restore()/reset()

Libraries such as Jest or Vitest have their own APIs for resetting mocks, so @fetch-mock/core deliberately only contains low level APIs for managing routes and call history. These will be wrapped in ways that are idiomatic for different test frameworks.

What's new or different

All these additions are intended to simplify the API.

.mock() renamed to .route()

.mock() previously did two jobs: mocking the fetch global and adding a route. This is replaced by .route(), which just adds a route.

Filtering calls

Related to the removal of inferred route names, matchers that are passed in to call history as filters are always executed as matchers, rather than first being coerced to a string to see if any route with that name exists. On the one hand, this is far more consistent and less confusing, but on the other it does mean that, in situations when you have many similar routes, and if you reliably want to retrieve the calls handled by a specific route, then you should really be giving your routes explicit names, e.g. fetchMock.route('http://my.site', 200, 'first-route')

CallLog

Previously items in the call history were returned as [url, options] arrays, with a few additional properties added. Now they are returned as objects - CallLogs - that contain all information about the call and how it was handled. The CallLog interface is also the expected input for matcher functions and response builder functions.

done() uses route names

Previously done() could be passed a matcher, or a boolean, or... the API was a mess to be honest; I'm not sure I even understood it's behaviour fully. Now it can be passed one or more route names, or nothing at all to check all routes. Much simpler

.removeRoutes() and .clearHistory()

These are the new methods for resetting fetch mock to its default state. The naming is a bit less ambiguous than the previous reset()/restore() (each testing library seems to have its own interpretation about what those verbs should mean).

.createInstance()

A replacement for sandbox() that eschews all the weird wiring that .sandbox() used. Possibly not very useful for the average user, but I use it a lot in my tests for fetch mock, so it stays :-).

What's still to come

There are a bunch of breaking changes I'd like to ship before getting to v1.0.0. I also want to give users an incentive to migrate so there are a variety of new features I'd like to add and bugs to fix. Have a look at the issues list and vote for any you like.

Introducing @fetch-mock/core

· 6 min read
fetch-mock maintainer

@fetch-mock/core is a new library for building up a mock fetch implementation, using (more or less) the same chainable API that you're familiar with from fetch-mock. It reimplements a lot of the fetch-mock API, but with some significant changes

What's gone from fetch-mock

Some features are removed from the @fetch-mock suite for good, while some have been removed from @fetch-mock/core, but will reappear in some form in wrappers of @fetch-mock/core that target specific toolchains.

Gone for good

Inferred route names

This was probably the worst design decision in fetch-mock. Inspecting calls that had been handled by a particular route relied on being able to pick out that route from the list of all routes. Early on fetch-mock mostly supported string matchers, and coercing a string into a string that could be used as a route name is trivial. However, later addition of Regex, Function, header matchers etc meant that this broke down, leading to a confusing experience when trying to retrieve the right fetch calls. In an earlier version I added the ability to explicitly name a route, and in fetch-mock@10 I also added a simpler API for specifying these names. With all that done, it's a lot easier to name, and later refer to, a given route without having to infer any names.

overwriteRoutes

Due to the inferred naming behaviour described above, fetch-mock was by default very fussy about allowing two different routes which had the same inferred name to be added. Now that inferred routes are gone, there is no longer any need for the user to specify whether or not to overwrite a previously added route; new routes are always added successfully without any reference to the prevoiuosly added routes.

Debug logging/warnOnFallback

warnOnFallback turned on logging if a call was handled by the route added by .catch(), or fell back to the network. And the application code was also peppered with lots of uses of the debug library. Taken together these added a lot of bulk to the code, making it harder to read and work with, and I've seen no evidence that users actually find it useful. It's possible I may add some debug logging back in future based on user feedback, but at this point I have no intention to.

.lastUrl(), .lastOptions(), .lastResponse()

These methods gave access to specific parts of teh last fetch call. With the introduction of the new CallLog format for logging calls these seem unnecessary e.g. .lastUrl() can be replaced by .lastCall().url.

.sandbox()

This was another very bad design decision that I'm very happy to get rid of. fetch-mock had no problem mocking global fetch, but in order to mock node-fetch I needed to provide a way to expose fetch-mock's mock of fetch, fetchHandler. I chose the way-too-clever approach of extending fetchHandler with the fetchMock object, so that my implementation of fetch also had methods .mock(), .catch() etc attached to it. This was the number one cause of conflict with Jest.

@fetch-mock/core takes the far more sensible approach of keeping the function and the fetchMock object separate, so that instead of

jest.mock(global, 'fetch', fetchMock.sandbox());
fetch.mock('http://my.site', 200);

you now have

jest.mock(global, 'fetch', fetchMock.fetchHandler);
fetchMock.mock('http://my.site', 200);

which keeps fetch-mock's methods much further away from any other library's workings.

.getAny(), .postAny(), .putAny(), .deleteAny(), .headAny(), .patchAny(), .getAnyOnce(), .postAnyOnce(), .putAnyOnce(), .deleteAnyOnce(), .headAnyOnce(), .patchAnyOnce()

While .getOnce() etc feel very useful, the any and anyOnce variants added a lot of repetition to the code and types, and don't actually add much value.

.___AnyOnce(response, options)

Creates a route that responds to any single request using a particular http method.

Gone, but back soon

The following features will return in other libraries that wrap @fetch-mock/core for different environments.

.mock()

@fetch-mock/core does not implement any functionality for replacing global fetch or a local fetch implementation (such as node-fetch) with a mock implementation.

.spy()/fallbackToNetwork

As @fetch-mock/core does not do anything to replace the native fetch implementation, these features - which pass through the fetch-mock implementation and go straight to the native implementation - are also concersn that will be added to wrappers.

restore()/reset()

Libraries such as Jest or Vitest have their own APIs for resetting mocks, so @fetch-mock/core deliberately only contains low level APIs for managing routes and call history. These will be wrapped in ways that are idiomatic for different test frameworks.

What's new or different

All these additions are intended to simplify the API.

.mock() renamed to .route()

.mock() previously did two jobs: mocking the fetch global and adding a route. This is replaced by .route(), which just adds a route.

Filtering calls

Related to the removal of inferred route names, matchers that are passed in to call history as filters are always executed as matchers, rather than first being coerced to a string to see if any route with that name exists. On the one hand, this is far more consistent and less confusing, but on the other it does mean that, in situations when you have many similar routes, and if you reliably want to retrieve the calls handled by a specific route, then you should really be giving your routes explicit names, e.g. fetchMock.route('http://my.site', 200, 'first-route')

CallLog

Previously items in the call history were returned as [url, options] arrays, with a few additional properties added. Now they are returned as objects - CallLogs - that contain all information about the call and how it was handled. The CallLog interface is also the expected input for matcher functions and response builder functions.

done() uses route names

Previously done() could be passed a matcher, or a boolean, or... the API was a mess to be honest; I'm not sure I even understood it's behaviour fully. Now it can be passed one or more route names, or nothing at all to check all routes. Much simpler

.removeRoutes() and .clearHistory()

These are the new methods for resetting fetch mock to its default state. The naming is a bit less ambiguous than the previous reset()/restore() (each testing library seems to have its own interpretation about what those verbs should mean).

.createInstance()

A replacement for sandbox() that eschews all the weird wiring that .sandbox() used. Possibly not very useful for the average user, but I use it a lot in my tests for fetch mock, so it stays :-).

What's still to come

There are a bunch of breaking changes I'd like to ship before getting to v1.0.0. I also want to give users an incentive to migrate so there are a variety of new features I'd like to add and bugs to fix. Have a look at the issues list and vote for any you like.

A new beginning for fetch-mock

· 4 min read
fetch-mock maintainer

Long time users of fetch-mock may have noticed two things recently

  1. This documentation website has had a makeover
  2. I'm actually maintaining it again

These two things are closely related. Allow me to explain

Why I stopped maintaining fetch-mock

As well as the perennial issue familiar to open source maintainers - working long hours for no pay, and not every user being particularly considerate in their feedback - I was also quite frustrated with the node.js and testing landscape.

ECMAScript modules were slowly landing, and causing a lot of disruption and conflicts with different toolchains. Increasingly bug reports were about these conflicts in the wider ecosystem, rather than my code specifically. Really not a lot of fun to fix. Additionally, choices I'd made in fetch-mock's API design predated the arrival of the testing leviathan that is Jest. In trying to play nicely with Jest I found myself implementing hack after hack, and still being at the mercy of changes to Jest's internals. And don't even get me started on the demands to support typescript.

I wasn't being paid and I increasingly felt like I wasn't maintaining a tool, rather piloting a rickety, outdated craft through choppy waters.

Who needs that in their life?

Why I'm back

I've been unemployed for the last ~4 months, taking stock of my career and where it might head next. Early on in this lull the people from TEA protocol got in touch. It's a new way of funding open source and, while I am sceptical that it will catch on (though I hope I'm wrong), it did get me thinking a bit more about open source and fetch-mock. After 3 months of not touching a text editor I figured I also needed to be careful of not getting too rusty; a conversation with a friend uncovered that I'd forgotten the keyboard shortcut to clear the terminal.

What I'm working on

I began by setting out some high level principles to guide the work.

  1. Be modern - support ESM, native global fetch & types by default. Pay as little attention to supporting other patterns as possible.
  2. Avoid making API choices that conflict with current, or future, testing toolchains. Remove any APIs that already conflict.
  3. Allow users to use fetch-mock in a way that is idiomatic to their choice of toolchain

Guided by these I came up with a plan of attack:

1. Migrate everything to ESM and to global native fetch

This was released as fetch-mock@10 about a month ago

2. Turn fetch-mock into a monorepo

This makes it possible to publish multiple packages aimed at different use cases and environments. I now use conventional commits and release-please, which took a while to get right.

I did this last week

3. Publish a @fetch-mock/core library

This contains only the functionality that I'm confident other testing tools won't implement, e.g. it does not contain functionality for actually replacing fetch with a mock implementation; testing libraries generally have their own APIs for doing that.

I did this a few days ago

4. Publish a suite of @fetch-mock libraries

Each will be targeted at a particular toolchain, such as @fetch-mock/standalone, @fetch-mock/jest, @fetch-mock/vitest...

I've not started on this yet.

Why the new website

Two reasons

  1. With the new @fetch-mock suite there'll be a lot more to document, and there'll also need to be very clear separation between legacy (fetch-mock) and modern (@fetch-mock) documentation.
  2. Static site generators have come a long way since I first put the fetch-mock site together, so worth looking again at the available tools.

So you are now reading from a new site I put together with docusaurus. I tried astro/starlight too, but it doesn't yet support having multiple different documentation subsites hosted within a single site. Docusaurus does this very well.

While @fetch-mock/core is not really intended for direct use, you could e.g. do something like this in jest.

import fetchMock from '@fetch-mock/core'
jest.mock(global, 'fetch', fetchMock.fetchHandler)

it('works just fine', () => {
fetchMock.route('http://here.com', 200);
await expect (fetch('http://here.com')).resolves
})

I'd be very happy if people could start giving the library and its docs an experimental little whirl.