Introducing Mentoss: The fetch mocker
A new approach to mocking global fetch() calls that works in both browsers and server-side runtimes.
When I first started using node-fetch
1 for fetch()
requests in Node.js, I quickly came across Nock2 as a mocking library for testing those requests. I thought Nock was quite well-written, and because it intercepted all network requests in Node.js, it could be used for more than just fetch()
requests. Unfortunately, Nock didn’t adapt to include support for native fetch()
, and so I had to search for other alternatives. (Update: Nock released native fetch()
support three days ago3).
I tried MSW4 next. Originally meant to mock out service workers in browsers, MSW also has support for mocking native fetch()
calls. I found the onboarding for MSW very difficult with few canonical examples to follow. I eventually got some tests working, but the experience was painful enough that I resolved to keep looking for a different library.
I next came across Fetch Mock5. I liked this better than MSW as it was laser-focused on mocking fetch()
requests. Unfortunately, I ran into a similar problem in that anything more than a simple request took me a long time to get working correctly. I found the documentation difficult to work with, and in some cases it seemed incorrect.
It was at this point, frustrated after spending another day trying to mock out fetch()
requests, that I decided I’d just have to write the library I wanted.
Enter Mentoss
Mentoss (lovingly named after the candy you’re thinking of) is a new approach to mocking global fetch()
calls. It takes the learnings from Nock, MSW, and Fetch Mock and attempts to smooth down the rough edges while providing a better developer experience. Fundamentally, Mentoss uses the same concepts as other mocking libraries: mocking out routes for specific requests and then replying with specific responses. However, I like to think that the developer experience, and the documentation, is significantly better.
How Mentoss works
At the center of Mentoss are two classes: MockServer
and FetchMocker
. The MockServer
class allows you to create a completely mocked-out server tied to a particular base URL. You can then assign routes to the server using methods for each HTTP verb. Here’s an example:
import { MockServer } from "mentoss";
const server = new MockServer("https://api.example.com");
// example route for GET /ping
server.get("/ping", 200);
This example creates a route for https://api.example.com/ping
and returns a 200 status code when the request is received.
You can then assign this server into a FetchMocker
instance:
import { MockServer, FetchMocker } from "mentoss";
const server = new MockServer("https://api.example.com");
server.get("/ping", 200);
const mocker = new FetchMocker({
servers: [server],
});
const { fetch } = mocker;
const response = await fetch("https://api.example.com/ping");
console.log(response.status); // 200
The FetchMocker
instance in this example is tied to the MockServer
instance, and you can use the generated fetch
property as a replacement for global fetch()
to try it out. In practice, though, you’ll probably use the mockGlobal()
method to replace the global fetch()
function with the one generated by the FetchMocker
. Here’s a complete example in a test:
import { MockServer, FetchMocker } from "mentoss";
import { expect } from "chai";
describe("My API", () => {
let mocker;
const server = new MockServer("https://api.example.com");
mocker = new FetchMocker({
servers: [server]
});
before(() => {
mocker.mockGlobal();
});
// reset the server after each test
afterEach(() => {
mocker.clearAll();
});
after(() => {
mocker.unmockGlobal();
});
it("should return a 200 status code", async () => {
// set up the route to test
server.get("/ping", 200);
// make the request
const response = await fetch("https://api.example.com/ping");
// check the response
expect(response.status).to.equal(200);
});
});
So far, this looks a lot like the other options that you have for mocking fetch()
, and that’s by design so that the API will feel familiar. However, there are some features that I think set Mentoss apart from the others.
Note: You can also create routes with extended request patterns and respond with extended response patterns.
Single-use routes
First is the concept of single-use routes. One of the challenges I had with the other libraries was in sequencing mocked requests, some of which need to return different responses depending on when they were called in the sequence. For example, imagine that you’d like to call GET /user/123
to verify the user exists, then DELETE /user/123
to delete the user, and then call GET /user/123
to verify the user was deleted. With other libraries, you’d need to mock out GET /user/123
with a function that would optionally return 200 or 404. With single-use routes, you can mock out the exact sequence you want:
import { MockServer, FetchMocker } from "mentoss";
const server = new MockServer("https://api.example.com");
server.get("/users/123", 200);
server.delete("/users/123", 204);
server.get("/users/123", 404);
const mocker = new FetchMocker({
servers: [server],
});
const { fetch } = mocker;
const response1 = await fetch("https://api.example.com/users/123");
console.log(response1.status); // 200
const response2 = await fetch("https://api.example.com/users/123", { method: "DELETE" });
console.log(response2.status); // 204
const response3 = await fetch("https://api.example.com/users/123");
console.log(response3.status); // 404
Even though GET https://api.example.com/users/123
is called once, it returns different responses depending on when it is called. The response is 200 the first time it’s called and 404 the second time. This approach makes mocking sequences easy and ensures your code doesn’t accidentally call the same route more than once without your knowledge (a common source of errors).
Route URL patterns
Another thing that bothered me with the existing solutions is that they all had different ways to partially match URLs. Whether that be magic strings or regular expressions or matcher functions. I felt like this should be easier, so Mentoss supports URL matching using the URLPattern
6 class. So you can create routes the same way you would using URLPattern
directly:
import { MockServer } from "mentoss";
const server = new MockServer("https://api.example.com");
// match GET /users/123, /users/456, /users/foo
server.get("/users/:id", 200);
// match GET/users/123 only
server.get({
url: "/users/:id"
params: {
id: 123
}
}, 200);
// match GET /users, /users/123, /users/456, /users/foo
server.get("/users/:id?", 200);
By using a standard syntax for URL matching, it’s easier to move between an actual server implementation and the client tests.
Easier route mismatch debugging
Perhaps the worst part of the developer experience with other libraries is what happens when you match a fetch()
request that doesn’t match any of the registered routes. You might get a live network request or an opaque error message that simply says “No route match request GET https://api.example.com/users
.” I spent countless hours trying to track down route mismatches, frequently needing to debug into the mocking library itself to find out exactly what was going on.
I found that most of the time the route mismatch was caused by a header or query string that didn’t match, but that was never surfaced in the error message. (I understand why: they’re just checking if the request matches, not why it does or doesn’t match.) In Mentoss, route mismatches are presented with a helpful list of partial route matches. Here’s an example:
No route matched for POST https://api.example.com/user/settings.
Full Request:
POST https://api.example.com/user/settings
Authorization: Bearer XYZ
Content-Type: application/json
{"name":"value"}
Partial matches:
🚧 [Route: POST https://api.example.com/user/:id -> 200]:
✅ URL matches.
✅ Method matches: POST.
❌ URL parameters do not match. Expected id=123 but received id=settings.
🚧 [Route: POST https://api.example.com/user/settings -> 200]:
✅ URL matches.
✅ Method matches: POST.
❌ Headers do not match. Expected authorization=Bearer ABC but received authorization=Bearer XYZ.
Mentoss keeps track of not just which routes match but why they match so that any mismatched route is presented with some likely alternatives. This feature saved me so much time during the development of Mentoss itself that I’m fairly confident it will be a time saver for you, as well.
First-class browser support
While other libraries support mocking of fetch()
requests in a browser, the support is fairly limited and mostly based on ensuring that any test running in Node.js is also capable of running in a browser. The browser’s implementation of fetch()
, however has some significant differences from server-side implementations of fetch()
.
Relative URL support
One significant difference between the browser and server-side runtimes is in support of relative URLs. For example:
// works in browsers, throws an error in Node.js
const response = await fetch("/ping");
When a fetch()
request is made from a web page, the page’s location is used as the base URL for any relative fetch()
requests; Node.js has no web page with a URL to use, so relative URLs cause fetch()
to throw an error.
In Mentoss, you can set baseUrl
for a FetchMocker
instance, ensuring that relative URLs will be resolved correctly:
import { MockServer, FetchMocker } from "mentoss";
const server = new MockServer("https://api.example.com");
server.get("/ping", 200);
const mocker = new FetchMocker({
servers: [server],
baseUrl: "https://api.example.com"
});
const { fetch } = mocker;
const response = await fetch("/ping");
console.log(response.status); // 200
Here, any relative URLs are resolved against https://api.example.com
, effectively allowing a browser-like behavior for fetch()
. You don’t even have to run these tests in a browser because the baseUrl
option works in server-side runtimes too.
CORS support
Another significant difference between browsers and server-side runtimes is the use of Cross-Origin Resource Sharing (CORS)7, which allows browsers to make requests across different domains. Mentoss supports CORS requests when the baseUrl
option is set for a FetchMocker
instance. Any requests sent to a different domain is subject to a CORS check to determine if it qualifies as a simple request or requires a preflight request. If a preflight request is required, Mentoss automatically triggers an OPTIONS request and interprets the headers sent back before making the original request. You can configure the OPTIONS response in the same way you would any other route, as in this example:
import { MockServer, FetchMocker } from "mentoss";
const server = new MockServer("https://api.example.com");
const mocker = new FetchMocker({
servers: [server],
baseUrl: "https://www.example.com",
});
// this is for the preflight request
server.options("/ping", {
status: 200,
headers: {
"Access-Control-Allow-Origin": "https://www.example.com",
"Access-Control-Allow-Headers: X-Mentoss"
},
});
// this is the route that we want to call
server.get("/ping", {
status: 200,
headers: {
"Access-Control-Allow-Origin": "https://www.example.com",
},
body: { message: "pong" },
});
const { fetch } = mocker;
// make the request
const response = await fetch("https://www.example.com/ping", {
headers: {
"X-Mentoss": "0.1.0"
},
});
const body = await response.json();
console.log(body.message); // "pong"
Mentoss CORS support means you can effectively test any browser request requiring CORS just like you would any other request.
Conclusion
Mentoss aims to simplify the process of mocking fetch()
requests, providing a more intuitive and developer-friendly experience. By incorporating features like single-use routes, URL pattern matching, detailed route mismatch debugging, and first-class browser support, Mentoss addresses many of the pain points found in other libraries. Whether you’re working on a Node.js application or a browser-based project, Mentoss offers a robust solution for testing your fetch()
requests with ease. Give it a try and see how it can improve your testing workflow.