DEV Community

Cover image for Type-safe API mocking with Mock Service Worker and TypeScript
Artem Zakharchenko
Artem Zakharchenko

Posted on • Edited on

Type-safe API mocking with Mock Service Worker and TypeScript

NOTE: This article is about MSW v1. We released MSW v2, which brings improvements to type-safety along with other features and bug fixes. Please read the Using with TypeScript page for type-safe API mocking on MSW v2. Thank you!

Mock Service Worker is a seamless API mocking library for browser and Node.js. It uses Service Worker API to intercept requests on the network level, meaning no more stubbing of "fetch", "axios", or any other request issuing client. It provides a first-class experience when mocking REST and GraphQL API, and allows you to reuse the same mocks for testing, development, and debugging.

Watch this 4 minutes tutorial on mocking a basic REST API response with Mock Service Worker to get a better understanding of how this library works and feels:

Today we're going to have a practical dive-in into adding TypeScript to your API mocking experience to bring it one step further.

Why annotate mocks?

The mocks you write are a part of your application like any other piece of logic. Having a type validation is one of the cheapest and most efficient ways to ensure your mocks satisfy the data expectations towards them.


REST API

Each REST request handler has the following type signature:

type RestHandler = <RequestBody, ResponseBody, RequestParams>(mask, resolver) => MockedResponse
Enter fullscreen mode Exit fullscreen mode

This allows us to annotate three things in our REST API handlers:

  1. Request body type.
  2. Response body type.
  3. Request parameters.

Let's take a look at the UPDATE /post/:postId request that utilizes all three said generics:

import { rest } from 'msw'

// Describe the shape of the "req.body".
interface UpdatePostRequestBody {
  title: "string"
  viewsCount: string
}

// Describe the shape of the mocked response body.
interface UpdatePostResponseBody {
  updatedAt: Date
}

// Describe the shape of the "req.params".
interface UpdatePostRequestParams {
  postId: string
}

rest.update
  <UpdatePostRequestBody, UpdatePostResponseBody, UpdatePostRequestParams>(
  '/post/:postId',
  (req, res, ctx) => {
    const { postId } = req.params
    const { title, viewsCount } = req.body

    return res(
      ctx.json({
        updatedAt: Date.now()
      })
    )
  })
Enter fullscreen mode Exit fullscreen mode

The same generics apply to any rest request handler: rest.get(), rest.post(), rest.delete(), etc.

GraphQL API

A type signature for the GraphQL handlers is:

type GraphQLHandler = <Query, Variables>(args) => MockedResponse
Enter fullscreen mode Exit fullscreen mode

This means we can annotate the Query type (what gets returned in the response) and the Variables of our query.

Let's take a look at some concrete examples.

GraphQL queries

import { graphql } from 'msw'

// Describe the payload returned via "ctx.data".
interface GetUserQuery {
  user: {
    id: string
    firstName: string
    lastName: string
  }
}

// Describe the shape of the "req.variables" object.
interface GetUserQueryVariables {
  userId: string
}

graphql.query
  <GetUserQuery, GetUserQueryVariables>(
  'GetUser',
  (req, res, ctx) => {
    const { userId } = req.variables

    return res(
      ctx.data({
        user: {
          id: userId,
          firstName: 'John',
          lastName: 'Maverick'
        }
      })
    )
  })
Enter fullscreen mode Exit fullscreen mode

GraphQL mutations

Now, let's apply the same approach to a GraphQL mutation. In the case below we're having a UpdateArticle mutation that updates an article by its ID.

import { graphql } from 'msw'

interface UpdateArticleMutation {
  article: {
    title: "string"
    updatedAt: Date
  }
}

interface UpdateArticleMutationVariables {
  title: "string"
}

graphql.mutation
  <UpdateArticleMutation, UpdateArticleMutationVariables>(
  'UpdateArticle',
  (req, res, ctx) => {
    const { title } = req.variables

    return res(
      ctx.data({
        article: {
          title,
          updatedAt: Date.now()
        }
      })
    )
  })
Enter fullscreen mode Exit fullscreen mode

GraphQL operations

When it comes to capturing multiple GraphQL operations regardless of their kind/name, the graphql.operation() truly shines. Although the nature of the incoming queries becomes less predictable, you can still specify its Query and Variables types using the handler's generics.

import { graphql } from 'msw'

type Query = 
  | { user: { id: string } }
  | { article: { updateAt: Date } }
  | { checkout: { item: { price: number } } }

type Variables = 
  | { userId: string }
  | { articleId: string }
  | { cartId: string }

graphql.operation<Query, Variables>((req, res, ctx) => {
  // In this example we're calling an abstract
  // "resolveOperation" function that returns
  // the right query payload based on the request.
  return res(ctx.data(resolveOperation(req)))
})
Enter fullscreen mode Exit fullscreen mode

Bonus: Using with GraphQL Code Generator

My absolute favorite setup for mocking GraphQL API is when you add GraphQL Code Generator to the mix.

GraphQL Code Generator is a superb tool that allows you to generate type definitions from your GraphQL schema, but also from the exact queries/mutations your application makes.

Here's an example of how to integrate the types generated by GraphQL Codegen into your request handlers:

import { graphql } from 'msw'
// Import types generated from our GraphQL schema and queries.
import { GetUserQuery, GetUserQueryVariables } from './types'

// Annotate request handlers to match 
// the actual behavior of your application.
graphql.query<GetUserQuery, GetUserQueryVariables>('GetUser', (req, res, ctx) => {})
Enter fullscreen mode Exit fullscreen mode

With your data becoming the source of truth for your request handlers, you're always confident that your mocks reflect the actual behavior of your application. You also remove the need to annotate queries manually, which is a tremendous time-saver!


Advanced usage

We've covered most of the common usage examples above, so let's talk about those cases when you abstract, restructure and customize your mocking setup.

Custom response resolvers

It's not uncommon to isolate a response resolver logic into a higher-order function to prevent repetition while remaining in control over the mocked responses.

This is how you'd annotate a custom response resolver:

// src/mocks/resolvers.ts
import type { ResponseResolver, RestRequest, RestContext } from 'msw'

interface User {
  firstName: string
  lastName: string
}

export const userResolver = (user: User | User[]): ResponseResolver<RestRequest, RestContext, User> => {
  return (req, res, ctx) => {
    return res(ctx.json(user))
  }
})
Enter fullscreen mode Exit fullscreen mode
import { rest } from 'msw'
import { userResolver } from './resolvers'
import { commonUser, adminUser } from './fixtures'

rest.get('/user/:userId', userResolver(commonUser))
rest.get('/users', userResolver([commonUser, adminUser])
Enter fullscreen mode Exit fullscreen mode

Custom response transformers

You can create custom context utilities on top of response transformers.

Here's an example of how to create a custom response transformer that uses the json-bigint library to support BigInt in the JSON body of your mocked responses.

// src/mocks/transformers.ts
import * as JsonBigInt from 'json-bigint'
import { ResponseTransformer, context, compose } from 'msw'

// Here we're creating a custom context utility
// that can handle a BigInt values in JSON.
export const jsonBigInt =
  (body: Record<string, any>): ResponseTransformer => {
    return compose(
      context.set('Content-Type', 'application/hal+json'),
      context.body(JsonBigInt.stringify(body))
    )
  }
Enter fullscreen mode Exit fullscreen mode

Note how you can compose your custom response transformer's logic by utilizing the compose and context exported from MSW.

You can use that jsonBigInt transformer when composing mocked responses in your handlers:

import { rest } from 'msw'
import { jsonBigInt } from './transformers'

rest.get('/stats', (req, res, ctx) => {
  return res(
    // Use the custom context utility the same way
    // you'd use the default ones (i.e. "ctx.json()").
    jsonBigInt({
      username: 'john.maverick',
      balance: 1597928668063727616
    })
  )
})
Enter fullscreen mode Exit fullscreen mode

Afterword

Hope you find this article useful and learn a thing or two about improving your mocks by covering them with type definitions—either manual or generated ones.

There can be other scenarios when you may find yourself in need to cover your mocks with types. Explore what type definitions MSW exports and take a look at the library's implementation for reference.

Share this article with your colleagues and give it a shoutout on Twitter, I'd highly appreciate it! Thank you.

Useful resources

Top comments (8)

Collapse
 
kettanaito profile image
Artem Zakharchenko

This needs to be updated to reflect the changes of generics order in the recent library releases: response body and request params have swapped places.

Collapse
 
wati_fe profile image
Boluwatife Fakorede

Hello Artem, great article, and thank you.

I do have a question, how do you define the response body interface if you want to return either an error response or a true response. Example below:

  rest.get<TodoId>(`${apiUrl}/todo`, async (req, res, ctx) => {
    const {todoId} = req.body
    const todo = await todosDB.read(todoId)
    if (!todo) {
      return res(
        ctx.status(404),
        ctx.json({status: 404, message: 'Todo not found'}),
      )
    }

    return res(ctx.json({todo}))
  }),
Enter fullscreen mode Exit fullscreen mode

Thank you very much

Collapse
 
kettanaito profile image
Artem Zakharchenko

Thank you, Fakorede.

With the current implementation you'd have to use a union type to define such response:

interface TodoResponsePayload {
  todo: Todo
}

interface TodoResponseError {
  status: number
  message: string
}

type TodoResponse = TodoResponsePayload | TodoResponseError

rest.get<TodoId, TodoResponse>('/todo', handler).
Enter fullscreen mode Exit fullscreen mode
Collapse
 
wati_fe profile image
Boluwatife Fakorede

Great. Thanks for your response

Collapse
 
gregostan profile image
Gregory Staniszewski • Edited

Thanks for the article. I've got a question - Is it possible to add types for query params?

For example when we want to mock a request:

GET /customers?query=aaa
rest.get<null, CustomerSearchApi[]>('/customers', (req, res, ctx) => {
    const query = req.url.searchParams.get('query');
})
Enter fullscreen mode Exit fullscreen mode

As I understand, the 3rd generic type can be used for request params like below:

rest.get<null, CustomerSearchApi[], {id: string}>('/customers/:id', (req, res, ctx) => {
    const { id } = req.params;
})
Enter fullscreen mode Exit fullscreen mode

Thanks in advance!

Collapse
 
kettanaito profile image
Artem Zakharchenko

Hey!

Query parameters are always either a string or an array of strings. You can provide a narrower type using type casting:

const userId = req.url.searchParams.get('userId') as string
Enter fullscreen mode Exit fullscreen mode
Collapse
 
bujardeari profile image
Bujar Deari

Hi Artem,

I have copy pasted your example code but i get an error on:

return res(ctx.json(user))

It says "Property 'json' does not exist on type..."

Collapse
 
kettanaito profile image
Artem Zakharchenko

Note that DEV.to wraps certain types in quotes ("string"). You don't need to do that. I'm not sure why their formatting works that way, the source code does not have any quotes.