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-fetch1 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 URLPattern6 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.

Footnotes

  1. node-fetch

  2. Nock

  3. Nock v14.0.0

  4. MSW (Mock Service Worker)

  5. Fetch Mock

  6. URLPattern API

  7. Cross-Origin Resource Sharing (CORS)

Understanding JavaScript Promises E-book Cover

Demystify JavaScript promises with the e-book that explains not just concepts, but also real-world uses of promises.

Download the Free E-book!

The community edition of Understanding JavaScript Promises is a free download that arrives in minutes.