When single page apps (SPAs) were the dominant architecture, end-to-end testing (E2E) was more straightforward than it is today. Your app lived in the browser, your data came from an API, and most API requests were initiated from the browser. Popular E2E tools like Cypress could intercept and mock those requests.
Modern fullstack frameworks have changed that. In Next.js, Remix (React Router v7), and TanStack Start, loaders, actions, server components, and server functions all execute on the server which is outside the reach of E2E tooling.
This post outlines the approach we've landed on at Anedot after nine months of using it in production. Before getting into the implementation, let's first define what a good solution looks like.
E2E testing goals
Mock the boundaries of the app
Everything inside the app runs. Everything outside gets mocked.
Each test owns its mocks explicitly
Each test declares exactly which endpoints it needs and what those endpoints return. There is no shared mock state between tests.
Mock data is type checked
Accurate types are generated for APIs, and mock data is type checked to ensure that mock data matches the production.
No leaked requests
No requests leak to real external services, even in a staging environment.
Assert on requests
Each test must verify the headers and body of outgoing requests.
Parallelization
Parallelization works, meaning that mocks defined for one test is not mixed up with mocks for another test, regardless of how many tests are running concurrently.
Closing the gap
To make it easier to follow along with our implementation, I created a demo application using the following tech stack:
It's a simple demo with a single route that:
- Loads posts from JSONPlaceholder via a server function (
fetchPosts) wired into the route loader
const fetchPosts = createServerFn().handler(async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5",
);
if (!response.ok) {
throw new Error("Failed to fetch posts");
}
return response.json() as Promise<Post[]>;
});
- Creates posts via a second server function (
createPost) triggered by a form submission
const createPost = createServerFn({ method: "POST" })
.inputValidator((data: { title: string; body: string }) => data)
.handler(async ({ data }) => {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
body: JSON.stringify({
title: data.title,
body: data.body,
userId: 1,
}),
headers: { "Content-Type": "application/json; charset=UTF-8" },
});
if (!response.ok) {
throw new Error("Failed to create post");
}
return response.json() as Promise<Post>;
});
Core challenges
In SPA-era testing, you intercepted requests in the browser because that's where most requests originated. In modern fullstack frameworks, requests often generate from the server. If we want each test to mock those requests, there must be some coordination between each test and the server. Let's now jump into implementing a solution.
Implementation
Let's start with the usage in each test file. Notice the not-yet-defined registerMockHandlers function, we'll define that next.
tests/example.spec.ts
test("renders posts", async ({ page }, testInfo) => {
const posts: Post[] = [
{
id: 1,
title: "Post 1",
body: "Body 1",
userId: 1,
},
{
id: 2,
title: "Post 2",
body: "Body 2",
userId: 2,
},
];
await registerMockHandlers({
page,
testInfo,
handlers: [
{
url: "https://jsonplaceholder.typicode.com/posts?_limit=5",
request: {
method: "GET",
headers: {},
},
response: {
status: 200,
body: JSON.stringify(posts),
},
},
],
});
await page.goto("http://localhost:3000/");
await expect(
page.getByRole("heading", { name: "Recent Posts" }),
).toBeVisible();
for (const post of posts) {
await expect(page.getByRole("heading", { name: post.title })).toBeVisible();
await expect(page.getByRole("listitem", { name: post.body })).toBeVisible();
}
});
Implementing registerMockHandlers
The registerMockHandlers function does two things:
- Create a cookie
- Create a file
As you may know, cookies are sent with all HTTP requests initiated from the browser by default. By creating a cookie, we are adding extra information that our server can read. We use this cookie to tell the server which test initiated each request.
A file is created to persist the mock data so that the server can find it. Since the cookie tells the server what test is running, the server can use that information to find the corresponding file.
Here's the implementation so far:
tests/utils/register-mock-handlers.ts
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { Page, TestInfo } from "@playwright/test";
export default async function registerMockHandlers({
page,
testInfo,
handlers,
}: {
page: Page;
testInfo: TestInfo;
handlers: Array<MockHandler>;
}) {
const mockId = `${testInfo.file} - ${testInfo.title}`;
// Used to associate a mock response with a test
await page.context().addCookies([
{
name: "mockId",
value: mockId,
path: "/",
domain: "localhost",
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
// Write mock handlers to file for msw to read
const mockFilePath = join(
process.cwd(),
"tests",
"mocks",
`${encodeURIComponent(mockId)}.json`,
);
await mkdir(dirname(mockFilePath), { recursive: true });
await writeFile(
mockFilePath,
JSON.stringify({ handlers, mockId }, null, 2),
"utf-8",
);
}
However, this is not yet a complete solution. The main problem is that test runs are leaking requests to external services, meaning that the mock files are currently unused.
There are two things we can do to actually use our mocks:
- Use MSW to intercept server-side HTTP requests at the Node.js level.
- Forward
mockIdcookie values tomockIdheaders for each server side request.
Let's breakdown the implementation of each of the above.
Using MSW
MSW provides an Express-like API for intercepting server-side requests. MSW intercepts server-side HTTP requests by patching Node.js's native fetch, http, and https implementations. Since we're using TanStack Start, the server-side entry point is server.ts, which is where we'll initialize MSW.
To differentiate between development, test, and production environments, I'm using Vite's mode feature. Vite doesn't have a built-in "test" mode, so I'm using "staging" for this purpose.
Also, I've configured Playwright to run the app in staging mode.
playwright.config.ts
export default defineConfig({
webServer: {
command: 'pnpm run staging',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe',
},
...
})
src/server.ts
import handler, { createServerEntry } from "@tanstack/react-start/server-entry";
if (import.meta.env.MODE === "staging") {
const { server } = await import("../mocks/node");
server.listen();
}
export default createServerEntry({
async fetch(request) {
return handler.fetch(request);
},
});
mocks/node.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
The remaining file is the one that defines the handlers, which you can view here: https://github.com/persianturtle/playwright-with-per-test-server-side-mocks/blob/main/mocks/handlers.ts.
The handlers file is just over 200 lines of code, so I'll summarize what it does instead of inlining the code.
Defining types for each API endpoint
Here is where we would define the endpoints of our application. If you have generated types from your API, you would reuse those types here. Notice how we are using the Post type here. If Post were to change, then our test files would have corresponding type errors.
mocks/handlers.ts
export type MockHandler =
| {
url: `https://jsonplaceholder.typicode.com/posts${string}`;
request: {
method: "GET";
headers: Record<string, never>;
};
response: {
status: 200;
body: Post[];
};
}
| {
url: "https://jsonplaceholder.typicode.com/posts";
request: {
method: "POST";
headers: {
"Content-Type": "application/json; charset=UTF-8";
};
};
response: {
status: 201;
body: Post;
};
};
Using mockId
The remaining code is a simple Express-like router.
mocks/handlers.ts
export const handlers = [
http.all("*", async ({ request }) => {
// get the mockId
// find the associated file that registerMockHandler created
// get the handlers for the file
// find the handler that matches the request
// respond with the mock data if there is a match
// otherwise, respond with a 500, leaked request
// also, assert that the request headers and body match expected values
})
];
If there is a leaked request, or incorrect request headers and/or body, file(s) are written to the filesystem. We'll see how these files are used in the Additional improvements section.
Forwarding mockId
To recap, registerMockHandlers sets a cookie in the browser for each test. Cookies are automatically included in browser-initiated requests, but not in server-side ones. So we need a way to forward the mockId from the browser to the server. Furthermore, MSW must have access to that mockId in order to look up the right handler for each request.
In a TanStack Start server function, we can get the mockId from the request's cookie, and forward it to our API request.
const fetchPosts = createServerFn().handler(async () => {
const cookie = getRequest().headers.get("cookie") ?? "";
const mockId =
cookie
.split(";")
.find((c) => c.trim().startsWith("mockId="))
?.split("=")[1] ?? undefined;
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=5",
{
headers: {
...(import.meta.env.MODE === "staging" && mockId ? { mockId } : {}),
},
},
);
if (!response.ok) {
throw new Error("Failed to fetch posts");
}
return response.json() as Promise<Post[]>;
});
Now, we have the ability to have playwright tests with mocks for server side requests defined per test. Running pnpm run test --ui shows our mock data is rendered in Playwright.
Additional improvements
The mocks/handlers.ts logic includes writing files to the file system for errors. There are two types of errors that can occur:
- Incorrect request headers and/or body
- Leaked requests
To fail the test suite if either of these errors occur, we configure a globalTeardown file in playwright.config.ts.
playwright.config.ts
export default defineConfig({
globalTeardown: "./tests/utils/global-teardown.ts",
...
})
tests/utils/global-teardown.ts
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { INCORRECT_REQUEST_PREFIX } from "../../mocks/handlers";
export default async function globalTeardown() {
const TEST_RESULTS_DIR = join(process.cwd(), "test-results");
/**
* Check for leaked requests
*/
try {
const content = await readFile(
join(TEST_RESULTS_DIR, "leaked-requests.txt"),
"utf-8",
);
if (content.length > 0) {
throw new Error("Leaked requests detected");
}
} catch (error) {
// Only throw if it's not a "file not found" error
if (
!(error instanceof Error && "code" in error && error?.code === "ENOENT")
) {
throw error;
}
}
/**
* Check for incorrect request payloads
*/
try {
const files = await readdir(TEST_RESULTS_DIR);
if (files.some((file) => file.startsWith(INCORRECT_REQUEST_PREFIX))) {
throw new Error("Incorrect request payloads and/or headers");
}
} catch (error) {
// Only throw if it's not a "file not found" error
if (
!(error instanceof Error && "code" in error && error?.code === "ENOENT")
) {
throw error;
}
}
}
Now, our test suite will fail when we've forgotten to mock an endpoint, or have an incorrect request.
Frequently Asked Questions
What if I need the same endpoint to return different responses across multiple calls?
This is easily doable. The pattern for our user flows becomes:
- Call
await registerMockHandlers(...) - Perform user action (e.g. page load, button clicks, form submission, etc.)
- Repeat
Since registerMockHandlers is writing to the file system, MSW will have the correct handlers when the request is made.
Can I run tests concurrently?
Yes, since each file created by registerMockHandlers is scoped to a mockId, which is a combination of a test's file name and test name. Playwright guarantees this to be unique since each test file is guaranteed to have unique test names.
What if I'm using loaders or actions?
The pattern is the same as the server function example outlined in this blog post. First, a mockId cookie is set via registerMockHandlers, and then that mockId is forwarded to the API request.
How would I handle authentication?
The registerMockHandlers function can also create auth-related cookies as needed. At Anedot, we use AWS Cognito and store auth tokens in a session cookie. The registerMockHandlers function creates a mock auth token. Since auth tokens are used in API requests, and since we intercept and mock every API request, we have automated tests for our auth related user flows.
How would I mock a failed request?
You would update the MockHandler type to support both successful and unsuccessful responses.
mocks/handlers.ts
export type MockHandler =
| {
url: `https://jsonplaceholder.typicode.com/posts${string}`;
request: {
method: "GET";
headers: Record<string, never>;
};
response:
| {
status: 200;
body: Post[];
}
| {
status: 500;
body: undefined;
};
}
| {
url: "https://jsonplaceholder.typicode.com/posts";
request: {
method: "POST";
headers: {
"Content-Type": "application/json; charset=UTF-8";
};
};
response:
| {
status: 201;
body: Post;
}
| {
status: 500;
body: undefined;
};
};
Is it annoying to write the handlers array in
registerMockHandlers?
Once you have enough tests, you'll notice many repeated handler objects. At Anedot, we have helper functions to generate these objects.
Thoughts?
Server-side mocking is still a rough edge in the E2E testing ecosystem, and I don't think there's one right answer yet. If you've found a better approach, ran into issues with this one, or just have questions — drop a comment below.

Top comments (0)