Requests made by client can't be intercepted by msw in testing environment if client is created before msw server starts listening
Description
I was writing composable unit test in my Vue application. The composable import a openapi-fetch client to make API request. The test uses msw to intercept requests and respond with mock data.
I find out that I can not use the client created(or imported) Before the msw server start listening, or the requests made by the client won't be intercepted. Create and use the client After the server start listening solves the problem.
Reproduction
Run the following unit test. If reCreateClient() isn't called in the beforeAll() after msw server start listening, the test will fail.
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import createClient, { Client } from "openapi-fetch";
import type { paths } from "./schema"; // generated by openapi-typescript
const baseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
let ApiClient: Client<paths> = createClient({
baseUrl,
});
function reCreateClient() {
ApiClient = createClient({
baseUrl,
});
}
const mockTags = ["tag1", "tag2", "tag3"];
const server = setupServer(
http.get(`${baseUrl}/tags`, () => {
return HttpResponse.json({ tags: mockTags });
})
);
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
// recreate the client after the server is set up
// otherwise server won't be able to intercept the request
reCreateClient();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
describe("apiClient", () => {
it("should fetch articles", async () => {
const result = await ApiClient.GET("/tags").then(({ data }) => {
return data;
});
expect(result!.tags).toEqual(mockTags);
});
});
Expected result
I am not sure if it is intended behavior, and what causes it. I would like see some heads-up in the testing document page to prevent test failure caused by this.
Checklist
- [x] I’m willing to open a PR (see CONTRIBUTING.md)
I would say the behavior you described is expected. it’s important to start the MSW server (server.listen) before creating or using the client to make sure MSW can intercept the requests.
I believe this point is already indicated in the code comments, but What do you think? 👀
beforeAll(() => {
// NOTE: server.listen must be called before `createClient` is used to ensure
// the msw can inject its version of `fetch` to intercept the requests.
server.listen({
@yoshi2no In the given example, the client is created in the test. But in my case I want to create the client only once in a central place. This client is then used throughout the code base where I fetch data.
I don't want to test the client in my test. I want to write a test for a component that uses the client 😅
Even using beforeAll is too late for that, because the client already got created in that separate, central module.
Maybe we have to differentiate between "creating" and "using" the client? Calling MSW's server before using the client is fine, but before creating is impossible (with this setup). I think this is how most people use the client, so what is the canonical way to do this?
Reusing the client seems fine in other situations. I don't know if there are more caveats (or pros and cons) using the Monolithic client like this.
I was having the same problem as @bennettdams pointed out
A simple workaround is to encapsulate createClient in a function, making every new api call create a new instance of createClient. At the point where an api call is made, server.listen() and worker.start() will likely already be initialized in your application
// Instead of this
export const api = createClient<paths>();
// Do this
export const api = () => createClient<paths>();
const { data, error } = await api().GET("/something");
I just don't know if this solution is computationally cheap. Can anyone shed some light on this?
I haven’t gotten to the root of “why” this is happening in msw (I’m not familiar with its internals). But createClient() is basically instant. It does no “work” other than saving your initial options to an object in memory. It can be recreated for every call and probably not impact runtime (we benchmark it and it’s virtually instant)
@drwpow In this case, the solution of wrapping createClient with a function seems to solve the problem
We started testing late, so I did a work arround to avoid refactoring dozens of calls by hand.
By using a proxy, I automate create a new client for tests everytime I'm acessing the client. And the code still the same for the production build.
import { ALMA_API_BASE_URL } from "@/constants/api";
import type { paths } from "../alma_api.generated";
import createClient, { Middleware } from "openapi-fetch";
let client = createClient<paths>({
baseUrl: ALMA_API_BASE_URL.replace("v1", ""),
});
if (process.env.NODE_ENV === "test") {
const clientProxy = new Proxy(
{},
{
get: (_, prop) => {
const client: any = createClient<paths>({
baseUrl: ALMA_API_BASE_URL.replace("v1", ""),
});
return client[prop];
},
}
);
client = clientProxy as any;
}
let token: string | null = null;
export const updateToken = (accessToken: string | null) => {
token = accessToken;
};
const myMiddleware: Middleware = {
async onRequest(req) {
if (token) {
req.headers.set("Authorization", `Bearer ${token}`);
}
return req;
},
};
client.use(myMiddleware);
export default client;
Using proxy seems like a good way. Or we can put createClient() in the function call to setup your msw server.
@CharlieZhuo @TheCeloReis
You can just create fetch instance constructor:
const fetchClient = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
fetch: (...args) => fetch(...args),
});
@pstachula-dev Really? I just do not understand why authors have not pointed to this fix anywhere in the docs. I lost like 8 hours debugging it.. Thanks mate.