Published

MSW is a mocking library for API’s. What we’re gonna see is how to use msw for API mocking without necerrarily having it installed as a dependency. For our project, msw will be an optional dependency that developers will opt-in to install. We’ll be using the tmdb API for this project. You’ll need an API key if you want to run the project without the mocks. We’ll also be using Vite and styled-components.

Initializing the project

Terminal window
npm create vite@latest

Installing and configuring msw

To install msw locally without saving as dependency we run:

Terminal window
npm i msw --no-save

We need to first create a couple of types. Inside src, create a new directory called @types.

Inside @types, create two files:

  • msw.d.ts with the following:
declare module 'msw'
  • browser.d.ts with the content:
declare module 'msw/browser'

You’ll need to edit tsconfig to look for these types. Add this to it:

{
"compilerOptions": {
"typeRoots": ["./node_modules/@types", "./src/@types/**/*.ts"]
}
}

We need to update vite-env.d.ts:

declare module 'msw'
declare module 'msw/browser'

Now create a mocks directory inside src, and inside it create a msw directory. Our first file here will be msw-utils. The code is listed below:

export const isMswInstalled = async (): Promise<boolean> => {
try {
await import('msw')
return true
} catch (err) {
console.log(err)
return false
}
}

This is simply going to check if msw is installed.

The second file will be handlers. Here’s the code:

import { isMswInstalled } from './msw-utils'
import popularMovies from '../tmdb/popular-moviees.json'
export async function getHandlers() {
const isInstalled = await isMswInstalled()
if (isInstalled) {
try {
const { http, HttpResponse } = await import('msw')
const handlers = [
http.get('/api/movie/popular?language=en-US&page=1', () => {
const response = HttpResponse.json(popularMovies)
return response
}),
]
return handlers
} catch (err) {
console.error('Failed to import the package:', err)
throw err
}
} else {
console.log('Package is not installed.')
return []
}
}

Here’s the content for popular-movies.json:

{
"page": 1,
"results": [
{
"adult": false,
"backdrop_path": "/yDHYTfA3R0jFYba16jBB1ef8oIt.jpg",
"genre_ids": [28, 35, 878],
"id": 533535,
"original_language": "en",
"original_title": "Deadpool & Wolverine",
"overview": "A listless Wade Wilson toils away in civilian life with his days as the morally flexible mercenary, Deadpool, behind him. But when his homeworld faces an existential threat, Wade must reluctantly suit-up again with an even more reluctant Wolverine.",
"popularity": 8329.394,
"poster_path": "/8cdWjvZQUExUUTzyp4t6EDMubfO.jpg",
"release_date": "2024-07-24",
"title": "Deadpool & Wolverine",
"video": false,
"vote_average": 7.786,
"vote_count": 2029
},
{
"adult": false,
"backdrop_path": "/58D6ZAvOKxlHjyX9S8qNKSBE9Y.jpg",
"genre_ids": [28, 12, 18, 53],
"id": 718821,
"original_language": "en",
"original_title": "Twisters",
"overview": "As storm season intensifies, the paths of former storm chaser Kate Carter and reckless social-media superstar Tyler Owens collide when terrifying phenomena never seen before are unleashed. The pair and their competing teams find themselves squarely in the paths of multiple storm systems converging over central Oklahoma in the fight of their lives.",
"popularity": 4138.03,
"poster_path": "/pjnD08FlMAIXsfOLKQbvmO0f0MD.jpg",
"release_date": "2024-07-10",
"title": "Twisters",
"video": false,
"vote_average": 7.1,
"vote_count": 808
},
{
"adult": false,
"backdrop_path": "/stKGOm8UyhuLPR9sZLjs5AkmncA.jpg",
"genre_ids": [16, 10751, 12, 35],
"id": 1022789,
"original_language": "en",
"original_title": "Inside Out 2",
"overview": "Teenager Riley's mind headquarters is undergoing a sudden demolition to make room for something entirely unexpected: new Emotions! Joy, Sadness, Anger, Fear and Disgust, who’ve long been running a successful operation by all accounts, aren’t sure how to feel when Anxiety shows up. And it looks like she’s not alone.",
"popularity": 2869.101,
"poster_path": "/vpnVM9B6NMmQpWeZvzLvDESb2QY.jpg",
"release_date": "2024-06-11",
"title": "Inside Out 2",
"video": false,
"vote_average": 7.605,
"vote_count": 2420
},
{
"adult": false,
"backdrop_path": "/lgkPzcOSnTvjeMnuFzozRO5HHw1.jpg",
"genre_ids": [16, 10751, 35, 28],
"id": 519182,
"original_language": "en",
"original_title": "Despicable Me 4",
"overview": "Gru and Lucy and their girls—Margo, Edith and Agnes—welcome a new member to the Gru family, Gru Jr., who is intent on tormenting his dad. Gru also faces a new nemesis in Maxime Le Mal and his femme fatale girlfriend Valentina, forcing the family to go on the run.",
"popularity": 2809.037,
"poster_path": "/wWba3TaojhK7NdycRhoQpsG0FaH.jpg",
"release_date": "2024-06-20",
"title": "Despicable Me 4",
"video": false,
"vote_average": 7.33,
"vote_count": 1086
},
{
"adult": false,
"backdrop_path": "/3q01ACG0MWm0DekhvkPFCXyPZSu.jpg",
"genre_ids": [28, 80, 53, 35],
"id": 573435,
"original_language": "en",
"original_title": "Bad Boys: Ride or Die",
"overview": "After their late former Captain is framed, Lowrey and Burnett try to clear his name, only to end up on the run themselves.",
"popularity": 2434.522,
"poster_path": "/oGythE98MYleE6mZlGs5oBGkux1.jpg",
"release_date": "2024-06-05",
"title": "Bad Boys: Ride or Die",
"video": false,
"vote_average": 7.615,
"vote_count": 1589
}
],
"total_pages": 45637,
"total_results": 912731
}

This is going to mock the response for this url and will simply return the popularMovies json file that we are loading here.

The last file is browser. The content is as follows:

import { isMswInstalled } from './msw-utils'
export const getWorker = async () => {
const isInstalled = await isMswInstalled()
if (!isInstalled) {
throw new Error('MSW package is not installed.')
}
try {
const mswBrowser = await import('msw/browser')
const { setupWorker } = mswBrowser
const { getHandlers } = await import('./handlers')
const handlers = await getHandlers()
if (!handlers || handlers.length === 0) {
throw new Error('No valid handlers returned from getHandlers.')
}
return setupWorker(...handlers)
} catch (err) {
console.error('Failed to import the package or setup worker:', err)
throw err
}
}

Creating the msw service worker

We need to create the msw service worker. Run the following to create it inside public:

Terminal window
npx msw init ./public

This is the core of our mocking. It will check if msw is installed and will throw an error if not. It then will setup the worker using our previously created handlers.

Setting up main to enable mocks when mock is enabled

Now we need to setup main so that when a mock flag is set to true and it has msw installed it starts the worker for us.

Inside main:

const deferRender = async () => {
if (import.meta.env.VITE_MOCK_ENABLED === 'true') {
const { isMswInstalled } = await import('./mocks/msw/msw-utils.ts')
const hasMsw = await isMswInstalled()
if (!hasMsw) {
return
}
const { getWorker } = await import('./mocks/msw/browser.ts')
const worker = await getWorker()
return worker.start()
}
}
deferRender().then(() => {
const rootElement = document.getElementById('root')
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
)
} else {
console.error('Root element not found.')
}
})

We’re gonna need to set the flag VITE_MOCK_ENABLED to true when we want the API mocking enabled.

To have access to this env variable we need to change vite-env.d.ts:

/// <reference types="vite/types/importMeta.d.ts" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_MOCK_ENABLED: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

tsconfig also needs to be updated to include vite-env.d.ts. Here’s our tsconfig.json:

{
"compilerOptions": {
"allowImportingTsExtensions": true,
"noEmit": true,
"module": "Preserve",
"moduleResolution": "Bundler",
"typeRoots": ["./node_modules/@types", "./src/@types/**/*.ts"],
"lib": ["dom", "esnext"],
"jsx": "react"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.d.ts",
"vite-env.d.ts",
"vite.config.ts"
]
}

Starting to code our application

Finally, we’ll start coding our application code. To install styled-components type:

Terminal window
npm i styled-components

Creating the styles

Our first style will be movies-styled inside src/comps/movie/movies-styled:

import styled from 'styled-components'
export const StyledMovieList = styled.ul`
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-flow: column;
gap: 2em;
`
export const StyledMovieListItem = styled.li`
margin: 0;
padding: 0;
`

For the card we’ll create a card-styled in src/comps/card:

import styled from 'styled-components'
import { BaseClassPrefix } from '../../constants/sc-constants'
export const StyledCard = styled.div.attrs({
className: `cl--card`,
})`
display: flex;
border: 1px solid #ccc;
gap: 1em;
`
export const StyledCardCover = styled.div.attrs({
className: `cl--card-cover`,
})`
& > img {
height: 20em;
}
`
export const StyledCardContent = styled.div.attrs({
className: `cl--card-content`,
})`
padding-right: 1em;
`

Creating our views

Our first component will be a card. Inside src/comps/card:

import React from 'react'
import { StyledCard, StyledCardContent, StyledCardCover } from './card-styled'
interface CardProps {
imageUrl: string
renderContent: () => React.ReactNode
}
const Card = ({ imageUrl, renderContent }: CardProps) => {
return (
<>
<StyledCard>
<StyledCardCover>
<img src={imageUrl} />
</StyledCardCover>
<StyledCardContent>{renderContent()}</StyledCardContent>
</StyledCard>
</>
)
}
export default Card

Then we’ll create movie-list-item inside src/comps/movie:

import React from 'react'
import Card from '../card/card'
interface MovieListItemProps {
imageUrl: string
title: string
overview: string
}
const MovieListItem = ({ imageUrl, title, overview }: MovieListItemProps) => {
const renderContent = () => {
return (
<>
<h3>{title}</h3>
<p>{overview}</p>
</>
)
}
return (
<>
<Card imageUrl={imageUrl} renderContent={renderContent} />
</>
)
}
export default MovieListItem

Creating the movie hook

First, we’re gonna need to add a new env variable inside vite-env.d.ts:

interface ImportMetaEnv {
...
readonly VITE_TMDB_TOKEN: string
}

Then create the following hook inside src/hooks/movie.ts:

const baseUrl = '/api'
const token = import.meta.env.VITE_TMDB_TOKEN
const bearer = 'Bearer ' + token
const useMovie = () => {
const getMovies = async () => {
const url = `${baseUrl}/movie/popular?language=en-US&page=1`
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
Authorization: bearer,
'Content-Type': 'application/json',
},
})
const data = await res.json()
return data
}
return { getMovies }
}
export default useMovie

Creating the movie model

We need a model for the movie’s response, create a movie.ts file inside src/models:

export interface Movie {
adult: boolean
backdrop_path: string
genre_ids: number[]
id: number
original_language: string
original_title: string
overview: string
popularity: number
poster_path: string
release_date: string
title: string
video: boolean
vote_average: number
vote_count: number
}

Bringing all together

Finally, we’ll be able to connect everything in our movies component. Inside src/comps/movies.ts

import React, { useEffect, useState } from 'react'
import { StyledMovieList } from './movies-styled'
import MovieListItem from './movie-list-item'
import useMovie from '../../hooks/movie'
import { Movie } from '../../models/movie'
const baseImagePath = `https://image.tmdb.org/t/p/w500`
const Movies = () => {
const { getMovies } = useMovie()
const [movies, setMovies] = useState<Movie[]>([])
const fetchData = async () => {
try {
const data = await getMovies()
setMovies(data.results as Movie[])
} catch (err) {
console.log(err)
}
}
useEffect(() => {
fetchData()
}, [])
return (
<>
<StyledMovieList>
{movies?.map((movie, index) => (
<MovieListItem
key={index}
title={movie?.original_title}
overview={movie?.overview}
imageUrl={`${baseImagePath}${movie?.poster_path}`}
/>
))}
</StyledMovieList>
</>
)
}
export default Movies

Now we include in the app component:

import React from 'react'
import Movies from './comps/movie/movies'
const App = () => {
return (
<>
<Movies />
</>
)
}
export default App

And lastly, we need to configure vite so it can handle when msw is not installed and to proxy tmdb requests.

Install plugin-alias:

Terminal window
npm i @rollup/plugin-alias -D

To configure vite, update vite.config.ts with this:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import alias from '@rollup/plugin-alias'
async function resolveMswPath() {
try {
await import('msw')
return 'msw'
} catch (err) {
console.log(err)
return '/empty-module.js'
}
}
export default defineConfig(async () => {
const mswPath = await resolveMswPath()
return {
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'https://api.themoviedb.org/3',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
},
},
},
build: {
rollupOptions: {
plugins: [
alias({
entries: [{ find: 'msw', replacement: mswPath }],
}),
],
},
},
}
})

When using install with no-save, it will need to install it every time you mess with the packages, so don’t forget to run msw install again.

Terminal window
npm i msw --no-save

Also, don’t forget to add an .env file and set the env variables, like this:

VITE_MOCK_ENABLED=true
VITE_TMDB_TOKEN=your-tmdb-token

Or set in package.json.