DEV Community

Kazuhito Higashioka
Kazuhito Higashioka

Posted on

Streamlining development with mock API in React

Background

As a Frontend developer, before working on a new feature, it is important to address the dependency of API integration first. You might be wondering why this responsibility falls on you rather than the Backend developer. Actually, to be fair, it is the responsibility of both developers. This is because:

  1. It is the Frontend developer's responsibility to integrate with the Backend API in a timely manner. It is not ideal to wait for the Backend API to be deployed in the development or staging environment before the Frontend developer can start integrating with the API.
  2. As for the Backend developer, it is their responsibility to provide the complete API specifications.

In the team that I have worked with, before starting our own tasks, we make sure to discuss the "API contracts" first. The Backend team documents this in a Postman collection. What's nice about this is that they also provide examples which help the Frontend team understand the context of the response data.

Mocking the API

Obviously, we don’t want to wait for the API to be deployed to the development/staging environment. Assuming that by this time we already have the API specifications, we can start development without breaking the productivity of both Frontend and Backend developers. We can start by integrating with a mocked API. There are a lot of options available on how to mock an API. Some of them are:

For the sake of shortening this article, we went with using MSW since we are already using this in our tests/specs.

Implementation

For this article, let’s assume that we are building an app to allow users to create blog posts. The app must be able to determine whether the user can or cannot create posts.

1. Building the API client

By this time, we should already have the API specifications from the backend team, and we can proceed to create the API clients in our application.

When working on React projects, I prefer to make API calls using custom hooks as it makes it easier to manage multiple states such as loading, data, and error states in one place.

In this demonstration, let’s assume that we have received a couple of API specifications from the backend team:

  1. Get signed-in user information
  2. Create post

For both APIs, we will write a custom hook for each of them:

// src/api/use-get-user-information.ts

import { useEffect, useState } from 'react'
import apiEndpoint from '../paths/api-endpoint'

type Permission = 'posts.write'

export type GetMyInformationSuccessResponseData = {
  id: number
  name: string
  permissions: Permission[]
}

export default function useGetMyInformation() {
  const [loading, setLoading] = useState(false)
  const [data, setData] = useState<GetMyInformationSuccessResponseData | null>(
    null,
  )
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    const fetchMyInformationAsync = async () => {
      setLoading(true)

      try {
        const data = await getMyInformationAsync()
        setData(data)
      } catch (error) {
        if (error instanceof Error) {
          setError(error)
        }
      } finally {
        setLoading(false)
      }
    }

    fetchMyInformationAsync()
  }, [])

  return {
    loading,
    data,
    error,
  }
}

async function getMyInformationAsync() {
  const response = await fetch(apiEndpoint.me())
  const data: GetMyInformationSuccessResponseData = await response.json()
  return data
}
Enter fullscreen mode Exit fullscreen mode
// src/api/use-create-post.ts

import { useCallback, useState } from 'react'
import apiEndpoint from '../paths/api-endpoint'

type CreatePostParams = {
  title: string;
}

export type CreatePostResponseData = {
  id: number;
  title: string;
}

export default function useCreatePost() {
  const [data, setData] = useState<CreatePostResponseData | null>(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<Error | null>(null)

  const mutateAsync = useCallback(async (params: CreatePostParams) => {
    try {
      setLoading(true)
      setError(null)
      setData(null)

      const data = await createPostAsync(params)
      setData(data)
      return data
    } catch (error) {
      if (error instanceof Error) {
        setError(error)
      }
    } finally {
      setLoading(false)
    }
  }, [])

  return {
    data,
    loading,
    mutateAsync,
    error,
  }
}

async function createPostAsync(params: CreatePostParams) {
  const response = await fetch(apiEndpoint.createPost(), {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(params),
  })

  const data: CreatePostResponseData = await response.json()

  return data
}
Enter fullscreen mode Exit fullscreen mode
// src/paths/api-endpoint.ts

const prependPath = (path: string) => `${import.meta.env.VITE_API_URL}${path}`

const apiEndpoint = {
  me: () => prependPath('/api/me'),
  createPost: () => prependPath('/api/posts'),
}

export default apiEndpoint
Enter fullscreen mode Exit fullscreen mode

I prefer to define paths in a single file as this will come in handy later when writing specs.

2. Building the blog post create page/form

// src/pages/create-post/CreatePost.tsx

import { FormEvent } from 'react'
import useCreatePost from '../../api/use-create-post'
import useGetUserInformation from '../../api/use-get-user-information'

type CreatePostFormElement = HTMLFormElement & {
  readonly elements: HTMLFormControlsCollection & {
    title: HTMLInputElement
  }
}

export default function CreatePost() {
  const {
    data: userInformation,
    error: userInformationError,
    loading: userInformationIsLoading,
  } = useGetUserInformation()

  const {
    data: createdPostData,
    mutateAsync: createPostAsync,
    error: createPostError,
  } = useCreatePost()

  if (userInformationIsLoading) {
    return <p role="alert">Loading</p>
  }

  if (userInformationError) {
    return <p role="alert">{userInformationError.message}</p>
  }

  if (!userInformation) {
    return <p role="alert">No user information was retrieved.</p>
  }

  const hasWritePostPermission = userInformation.permissions.find(permission => permission == 'posts.write')

  if (!hasWritePostPermission) {
    return <p role="alert">The page you&apos;re trying access has restricted access.</p>
  }

  const handleFormSubmit = (event: FormEvent<CreatePostFormElement>) => {
    event.preventDefault()

    createPostAsync({
      title: event.currentTarget.elements.title.value,
    })
  }

  return (
    <div>
      <h1>Create Post</h1>

      <form onSubmit={handleFormSubmit}>
        <label>
          Title
          <input type="text" name="title" placeholder="Title" />
        </label>

        <button>Create post</button>

        {createdPostData ? (
          <p role="alert">Post created</p>
        ) : null}

        {createPostError ? (
          <p role="alert">{createPostError.message}</p>
        ) : null}
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Okay, so in this component, we are trying to achieve a couple of things:

  1. First, we check if the user has write post permission, which can result in either:
    1. Allowing the user to continue creating a post.
    2. Displaying a restricted access error message.
  2. Lastly, we allow the user to input and create a post record.

3. Mocking API calls in the browser

  1. Install msw
    Installing MSW is the same as outlined in their documentation.

  2. Handlers
    We'll start by creating the default handlers.

// src/mocks/browser-handlers.ts

import { HttpResponse, PathParams, http } from 'msw'
import apiEndpoint from '../paths/api-endpoint'
import { GetMyInformationSuccessResponseData } from '../api/use-get-user-information'
import { CreatePostParams, CreatePostResponseData } from '../api/use-create-post'

export const browserHandlers = [
  http.get(apiEndpoint.me(), () => {
    return HttpResponse.json<GetMyInformationSuccessResponseData>({
      id: 1000,
      name: 'John Doe',
      permissions: ['posts.write'],
    })
  }),

  http.post<PathParams, CreatePostParams, CreatePostResponseData>(apiEndpoint.createPost(), async ({ request }) => {
    const requestBody = await request.json()
    return HttpResponse.json({
      id: 2000,
      title: requestBody.title,
    })
  }),
]
Enter fullscreen mode Exit fullscreen mode

These handlers should be designed to support the 'get user information' and 'create post' API endpoints.

  1. Setup browser
// src/mocks/browser.ts

import { setupWorker } from 'msw/browser'
import { browserHandlers } from './browser-handlers'

export const worker = setupWorker(...browserHandlers)
Enter fullscreen mode Exit fullscreen mode

Then, in our main.tsx we should setup as:

// src/main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'

async function enableMocking() {
  if (import.meta.env.PROD) {
    return
  }

  const { worker } = await import('./mocks/browser')

  // `worker.start()` returns a Promise that resolves
  // once the Service Worker is up and ready to intercept requests.
  return worker.start()
}

enableMocking().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
  )
})
Enter fullscreen mode Exit fullscreen mode

Once done, we should be able to verify that it is working by checking in the browser’s developer console.

MSW activated

Network intercepted by MSW

Mocked create post API

Now we can also test for different scenario, such as:

// src/mocks/browser-handlers.ts

import { HttpResponse, PathParams, http } from 'msw'
import apiEndpoint from '../paths/api-endpoint'
import { GetMyInformationSuccessResponseData } from '../api/use-get-user-information'
import { CreatePostParams, CreatePostResponseData } from '../api/use-create-post'

export const browserHandlers = [
  http.get(apiEndpoint.me(), () => {
    return HttpResponse.json<GetMyInformationSuccessResponseData>({
      id: 1000,
      name: 'John Doe',
-     permissions: ['posts.write'],
+     permissions: [],
    })
  }),

  http.post<PathParams, CreatePostParams, CreatePostResponseData>(apiEndpoint.createPost(), async ({ request }) => {
    const requestBody = await request.json()
    return HttpResponse.json({
      id: 2000,
      title: requestBody.title,
    })
  }),
]
Enter fullscreen mode Exit fullscreen mode

Mock get user information

4. Writing specs

We knew that in our specifications, we also needed to configure server mocking so that the app would interact with a mock server instead of a real one.

  1. Setup node
// src/mocks/node.ts

import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
Enter fullscreen mode Exit fullscreen mode

In this example, we don't use default handlers because we will define handlers in each spec file.

// src/mocks/handlers.ts

export const handlers = []
Enter fullscreen mode Exit fullscreen mode

And finally, we’re going to set up the server on our test runner.

// vitest.setup.ts

import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './src/mocks/node'
import 'vitest-dom/extend-expect'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Enter fullscreen mode Exit fullscreen mode
  1. Write specs
// src/pages/create-post/CreatePost.test.tsx

import { fireEvent, render, screen } from '@testing-library/react'
import { HttpResponse, http } from 'msw'
import { beforeEach, describe, expect, test } from 'vitest'
import CreatePost from './CreatePost'
import { server } from '../../mocks/node'
import apiEndpoint from '../../paths/api-endpoint'
import { GetMyInformationSuccessResponseData } from '../../api/use-get-user-information'
import { CreatePostResponseData } from '../../api/use-create-post'

describe('when user has write post permission', () => {
  beforeEach(() => {
    server.use(
      http.get(apiEndpoint.me(), () => {
        return HttpResponse.json<GetMyInformationSuccessResponseData>({
          id: 1000,
          name: 'John Doe',
          permissions: ['posts.write'],
        })
      }),

      http.post(apiEndpoint.createPost(), () => {
        return HttpResponse.json<CreatePostResponseData>({
          id: 2000,
          title: 'My awesome post',
        })
      }),
    )

    render(<CreatePost />)
  })

  test('can create post', async () => {
    fireEvent.change(
      await screen.findByRole('textbox', { name: /title/i }),
      {
        target: { value: 'My awesome post' },
      },
    )

    fireEvent.click(
      screen.getByRole('button', { name: /create post/i }),
    )

    expect(
      await screen.findByText(/post created/i)
    ).toBeInTheDocument()
  })
})

describe('when user has no post permission', () => {
  beforeEach(() => {
    server.use(
      http.get(apiEndpoint.me(), () => {
        return HttpResponse.json<GetMyInformationSuccessResponseData>({
          id: 1000,
          name: 'John Doe',
          permissions: [],
        })
      }),
    )

    render(<CreatePost />)
  })

  test('cannot create post', async () => {
    expect(
      await screen.findByText(/the page you're trying access has restricted access./i)
    ).toBeInTheDocument()
  })
})
Enter fullscreen mode Exit fullscreen mode

Running specs

Alright, so with the specs we've written, we're able to cover at least two use cases. We can also add more specs to test how the app should behave in case there is an error when creating the post, and so on.

Conclusion

When building a new feature, it does not necessarily need to wait for the actual Backend API. Both teams just need to agree on what will be the API contract. Once we have the API contract, we can start mocking it on both the browser and test environment. Also, since we have full control we can test different scenarios by just changing the response data which helps us to improve the quality of the software that we’re building.

Source code available in GitHub repository.

Top comments (0)