DEV Community

loading...
Cover image for Type-safe API mocking with Mock Service Worker and TypeScript

Type-safe API mocking with Mock Service Worker and TypeScript

kettanaito profile image Artem Zakharchenko Updated on ・5 min read

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 { ResponseResolver } from 'msw'

interface User {
  firstName: string
  lastName: string
}

export const userResolver = (user: User | User[]): ResponseResolver => {
  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

Discussion (4)

pic
Editor guide
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 Author

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
kettanaito profile image
Artem Zakharchenko Author

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.