How We Built a Scalable Mocking Architecture with MSW and TypeScript
In this article, I’ll walk you through how we moved from scattered, fragile REST API mocks in a React App to a clean, type-safe REST APIs mocks system powered by MSW. It’s now a go-to pattern I bring into every serious frontend codebase, equally effective in unit tests, integration tests, and Storybook.
Old Approach: Handcrafted Mocks with Nock
A couple of years ago, I was working on a large-scale React application with hundreds of components and unit tests. We used Jest and React Testing Library, and we had an ever-growing number of test cases involving REST API mocks: happy paths, 404s, server errors, and edge cases with both minimal and excessive data.
Back then, we used Nock. Each test included handcrafted mocks inline, like this:
it("should render a list of users", async () => {
// Mock successful GET /api/users response
nock(API_HOST)
.get("/api/users")
.reply(200, [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
])
render(<UserListPage />)
expect(await screen.findByText("Alice")).toBeInTheDocument()
expect(screen.getByText("Bob")).toBeInTheDocument()
})
One mock might return a long list, another an empty array, a third might simulate a server error:
nock(API_HOST)
.get("/api/users")
.reply(500, { message: "Internal Server Error" })
It worked fine for simple cases: define a URL, specify a response, done. But as the project grew, it didn’t scale. Mocks were duplicated, untyped, and hard to maintain. There was no structure, no reuse, no type safety, and no autocomplete. The more tests we wrote, the more time we spent updating brittle mocks instead of shipping features.
That’s when we decided to build a better foundation for a scalable frontend testing.
Extracting Mock Classes by Feature
My colleague dkPups, who has extensive experience with Java, suggested that we create classes for each group of REST resources. These classes would encapsulate the mocking logic and make it type-safe. For example, we created a UserMock class to handle all HTTP interactions related to users:
// mock/user-mock.ts
import { nock } from 'nock'
import { API_ROOT } from '@/shared/config'
import type { TUser } from '@/entities/user'
// There were many more options here — I’ll explain them later
type ResponseOptions = { status?: number }
// Every mock class extended BaseMock with some wrappers for `nock(API_ROOT)`,
// but I’ve inlined everything here to simplify the example
export class UserMock extends BaseMock {
getAllUsers(users: TUser[], options: ResponseOptions = {}) {
const { status = 200 } = options
return nock(API_ROOT)
.get('/api/users')
.reply(status, users)
}
getUserById(userId: number, user: TUser | null, options: ResponseOptions = {}) {
const { status = 200 } = options
return nock(API_ROOT)
.get(`/api/users/${userId}`)
.reply(status, user)
}
createUser(user: TUser, options: ResponseOptions = {}) {
const { status = 201 } = options
return nock(API_ROOT)
.post('/api/users')
.reply(status, user)
}
updateUser(userId: number, user: TUser, options: ResponseOptions = {}) {
const { status = 200 } = options
return nock(API_ROOT)
.patch(`/api/users/${userId}`)
.reply(status, user)
}
}
Instead of manually writing nock().get(...)
inline in each test, we could now call methods on the UserMock class to mock these requests. Each method expects the required data to be passed in with proper types:
// tests/pages/UserListPage.test.tsx
import { UserMock } from '@/tests/mocks/user-mock'
import { render, screen } from '@testing-library/react'
import UserListPage from '@/pages/UserListPage'
const mock = new UserMock()
it('should render a list of users', async () => {
// Mock successful GET /api/users response
mock.getAllUsers([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
render(<UserListPage />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
it('should fetch and display a user by ID', async () => {
mock.getUserById(1, { id: 1, name: 'Alice' })
render(<UserProfilePage userId={1} />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
})
it('should show an error message when the user is not found', async () => {
mock.getUserById(1, null, { status: 404 })
render(<UserProfilePage userId={1} />)
expect(await screen.findByText('User not found')).toBeInTheDocument()
})
By delegating responsibility to classes like UserMock
, we achieved the following:
- All endpoint URLs and response types for a resource are defined in a single source of truth
- There’s no risk of typos in URLs or field names across different tests
- Changes to mock behavior (e.g. response format updates) only need to be made in one place
- Tests become cleaner and focus solely on the behavior being tested, not on the mock setup
One Mock, Multiple Contexts: Node and Browser Support
When I joined a new project, I encountered a familiar challenge: we needed to mock API calls for unit tests. But this time, there was an additional requirement — the same mocks had to work in Storybook too. This added an extra layer of complexity, since we needed a solution that worked both in Node and in the browser.
Drawing on our previous experience, I decided to organize the mocks into classes again. But instead of Nock, I chose MSW (Mock Service Worker) — a tool that can intercept HTTP requests in both browser and Node.js environments. That made it a perfect fit for a setup that needed to support unit tests and Storybook.
Here’s what using MSW in Node.js tests typically looks like:
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer()
it('should render a list of users', async () => {
// Mock successful GET /api/users response
server.use(
http.get('/api/users', () =>
HttpResponse.json(
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
],
{ status: 200 }
)
)
)
render(<UserListPage />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
And here’s how the same mock can be used in Storybook using msw-storybook-addon:
import { http, HttpResponse } from 'msw'
export const UserListPage = {
parameters: {
msw: {
handlers: [
http.get('/api/users', () =>
HttpResponse.json(
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
],
{ status: 200 }
)
)
]
}
}
}
As you can see, this part is identical in both contexts:
http.get('/api/users', () =>
HttpResponse.json(
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
],
{ status: 200 }
)
)
This makes it a great candidate for reuse. So, just like we did earlier with Nock, we can extract these definitions into a class:
// mocks/user-mock.ts
// mocks/user-mock.ts
import { rest, HttpResponse } from 'msw'
import { API_ROOT } from '@/shared/config'
import type { TUser } from '@/entities/user'
export class UserMock {
// Mock for getting all users
getAllUsers(users: TUser[]) {
return rest.get('/api/users', () => {
return HttpResponse.json(users, { status: 200 })
})
}
// Mock for getting a user by ID
getUserById(userId: number, user: TUser) {
return rest.get(`/api/users/${userId}`, () => {
return HttpResponse.json(user, { status: 200 })
})
}
// Mock for creating a user
createUser(user: TUser) {
return rest.post('/api/users', () => {
return HttpResponse.json(user, { status: 201 })
})
}
// Mock for updating a user
updateUser(userId: number, user: TUser) {
return rest.patch(`/api/users/${userId}`, () => {
return HttpResponse.json(user, { status: 200 })
})
}
}
Now our test becomes much more concise:
const mock = new UserMock()
it('should render a list of users', async () => {
server.use(
mock.getAllUsers([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
)
render(<UserListPage />)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
And the same applies to Storybook:
const mock = new UserMock()
export const UserListPage = {
parameters: {
msw: {
handlers: [
mock.getAllUsers([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
]
}
}
}
Now, the same mock system works seamlessly across unit tests, integration tests, and Storybook stories, reducing complexity and duplication while keeping our codebase clean and maintainable.
Parametrizing Mocks
Since we also have tests for pessimistic scenarios like server errors, we need to parameterize the HTTP status. Sometimes we also want to test how the UI behaves when the request is pending. MSW offers a delay()
function for that.
Additionally, we may want to mock sequential requests, for example:
- Fetch the user list
- Add a new user
- Refetch the user list — this time it should return the updated list
To handle that, we can use MSW’s once
option, which allows a mock to apply only once — great for simulating evolving server responses.
So let’s add an options
argument to our request methods to parametrize API mocks:
import { delay, http, HttpResponse } from 'msw'
type ResponseOptions = {
status?: number
delay?: number
once?: boolean
}
export class UserMock {
// Mock for getting all users
getAllUsers(users: TUser[], options: ResponseOptions = {}) {
const {
// Get the status from options, 200 by default
status = 200,
// Get the delay from options
delay: timeout,
// Get the once option
once
} = options
return rest.get(
'/api/users',
async () => {
if (timeout) await delay(timeout)
return HttpResponse.json(users, { status })
},
{ once }
)
}
// ... other methods can also accept delay and once
}
Refactoring with BaseMock
Since all mocks repeat the same boilerplate code for handling options like status
, delay
, and once
, we can extract this logic into a BaseMock
class as mentioned in the beginning:
import { delay, http, HttpResponse, type JsonBodyType } from 'msw'
export type ResponseOptions = {
status?: number
delay?: number
once?: boolean
}
export abstract class BaseMock {
protected baseApiUrl: string = '/api'
static createGetMock<T extends JsonBodyType>(
url: string,
response: T,
options: ResponseOptions = {}
) {
const { status = 200, delay: timeout, once } = options
return http.get(url, async () => {
if (timeout) await delay(timeout)
return HttpResponse.json(response, { status })
}, { once })
}
static createPostMock<T extends JsonBodyType>(
url: string,
response: T,
options: ResponseOptions = {}
) {
const { status = 201, delay: timeout, once } = options
return http.post(url, async () => {
if (timeout) await delay(timeout)
return HttpResponse.json(response, { status })
}, { once })
}
static createPatchMock<T extends JsonBodyType>(
url: string,
response: T,
options: ResponseOptions = {}
) {
const { status = 200, delay: timeout, once } = options
return http.patch(url, async () => {
if (timeout) await delay(timeout)
return HttpResponse.json(response, { status })
}, { once })
}
}
With this base class, we can rewrite our UserMock
pretty clear:
// mocks/user-mock.ts
import { type TUser } from '@/entities/user'
import { BaseMock, type ResponseOptions } from './base-mock'
export class UserMock extends BaseMock {
// Mock for getting all users
getAllUsers(users: TUser[], options: ResponseOptions) {
const url = `${this.baseApiUrl}/users`
return BaseMock.createGetMock(url, users, options)
}
// Mock for getting a user by ID
getUserById(userId: number, user: TUser, options: ResponseOptions) {
const url = `${this.baseApiUrl}/users/${userId}`
return BaseMock.createGetMock(url, user, options)
}
// Mock for creating a user
createUser(user: TUser, options: ResponseOptions) {
const url = `${this.baseApiUrl}/users`
return BaseMock.createPostMock(url, user, options)
}
// Mock for updating a user
updateUser(userId: number, user: TUser, options: ResponseOptions) {
const url = `${this.baseApiUrl}/users/${userId}`
return BaseMock.createPatchMock(url, user, options)
}
}
Conclusions
By refactoring and structuring our REST API mocks using MSW, we’ve made our frontend testing setup more scalable, readable, and reusable:
- Readability and maintainability are improved by providing meaningful classes with typed methods, encapsulating URLs and response types
- Mocks are configurable for optimistic, pessimistic, and edge-case scenarios using consistent options like
status
,delay
, andonce
- MSW-related code has been extracted into the BaseMock adapter to avoid duplication across mocks
- Adding new endpoints is easy, just plug in the right URL and data
- Developers can quickly mock different scenarios without rewriting handlers
You can find the complete code of all mentioned files in this GitHub Gist.