DEV Community

Yoshihiro Nakamura
Yoshihiro Nakamura

Posted on

Using msw to test React with GraphQL effectively

Testing React application is now easier than before thanks to the tools like jest, testing-library, jest-dom. But it gets kinda hard when you have to deal with side effects, especially api call. In this article, I'll show you how to test React with GraphQL easily and effectively by using msw.

Don't mock your client

When you search how to test React Component with GraphQL, you'll might see the articles or guides that shows how to mock graphql client or it's Provider.

import TestRenderer from 'react-test-renderer';
import { MockedProvider } from '@apollo/client/testing';
import { GET_DOG_QUERY, Dog } from './dog';

const mocks = [];

it('renders without error', () => {
  const component = TestRenderer.create(
    <MockedProvider mocks={mocks} addTypename={false}>
      <Dog name="Buck" />
    </MockedProvider>,
  );

  const tree = component.toJSON();
  expect(tree.children).toContain('Loading...');
});
Enter fullscreen mode Exit fullscreen mode

This is how apollo client instructs.

And for urql, it also instructs the way to mock client.

import { mount } from 'enzyme';
import { Provider } from 'urql';
import { never } from 'wonka';
import { MyComponent } from './MyComponent';

it('renders', () => {
  const mockClient = {
    executeQuery: jest.fn(() => never),
    executeMutation: jest.fn(() => never),
    executeSubscription: jest.fn(() => never),
  };

  const wrapper = mount(
    <Provider value={mockClient}>
      <MyComponent />
    </Provider>
  );
});
Enter fullscreen mode Exit fullscreen mode

Well, what's wrong with mocking?

  1. It's tied to particular GraphQL Client. Tests will be broken if you change the client library one to another.
  2. Mocked Provider possibly works different from real Provider running on production. What if your Provider includes complex logic that would affect your app's behavior?

MSW

https://mswjs.io/

MSW solves those problems. MSW (Mock Service Worker) is a REST/GraphQL API mocking library for browser and Node.js, that intercepts requests and act as a real server.

MSW intercepts requests on the network level, so by using msw in your test, you don't need to mock GraphQL Client, Provider anymore!

Then let's see how to write React component tests with msw.

Setup msw for testing

Example App

Before dive into msw, let's see how example app looks like.

Imagine we have a scheme like

  type Query {
    todos: [Todo!]!
  }

  type Mutation {
    saveTodo(todo: TodoInput!): Todo
  }

  type Todo {
    id: ID!
    title: String!
  }

  input TodoInput {
    title: String!
  }
Enter fullscreen mode Exit fullscreen mode

And your app fetches todos

import { useQuery } from 'urql';

const TodosQuery = `
  query {
    todos {
      id
      title
    }
  }
`;

const Todos = () => {
  const [result] = useQuery({
    query: TodosQuery,
  });

  const { data, fetching, error } = result;

  if (fetching) return <p>Loading...</p>;
  if (error) return <p>Oh no... {error.message}</p>;

  return (
    <ul>
      {data.todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

msw setup

Following their docs, we should specify 3 files at first. Thanks to msw, you can define mock data fully type safely.

mocks/handlers.ts

import { graphql } from 'msw'
import { GetTodosDocument } from 'src/generated/graphql.ts/graphql'

export const handlers = [
  graphql.query(GetTodosDocument, (req, res, ctx) =>
    res(
      ctx.data({
        todos: [todoFactory(), todoFactory()], // fully typed
      })
    )
  ),
]
Enter fullscreen mode Exit fullscreen mode

In this file, define your default handlers, which is supposed to be used in your tests widely. Each handlers can be overwritten in each test case.

todoFactory() is the mock factory function. I'll explain it later but it's just a function that returns mock data of todo.

mocks/server.ts

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

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

jest.setup.ts

import { server } from './mocks/server'

// Establish API mocking before all tests.
beforeAll(() => {
  server.listen()
})

// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => {
  server.resetHandlers()
})

// Clean up after the tests are finished.
afterAll(() => server.close())
Enter fullscreen mode Exit fullscreen mode

Last two files are just template files.

Custom Render setup

As testing-library encourages, it's useful to define custom render. You can use your Graphql Client Provider that is used in production.

import { render } from '@testing-library/react'
import { GraphQLHandler, GraphQLRequest } from 'msw'

import { UrqlClientProvider } from './components/util/UrqlClientProvider'
import { server } from './mocks/server'

export const testRenderer =
  (children: React.ReactNode) =>
  (responseOverride?: GraphQLHandler<GraphQLRequest<never>>) => {
    if (responseOverride) {
      server.use(responseOverride)
    }
    render(<UrqlClientProvider>{children}</UrqlClientProvider>)
  }
Enter fullscreen mode Exit fullscreen mode

Here testRenderer can accept responseOverride, which is aimed at overriding existing handler we defined earlier in mock/handlers.ts.

Write tests!

Basic

Now it's time to write actual tests! So for the Happy Path, we don't need to override default handlers, so just call renderPage function without parameters.

describe('Todos Page', () => {
  const renderPage = testRenderer(<Todos />)

  it('displays fetched todo list', async () => {
    renderPage()
    const target = await screen.findAllByTestId('todo')
    expect(target.length).toBe(2)
  })
})
Enter fullscreen mode Exit fullscreen mode

Override handlers for edge case tests

And if you want to test edge case or when the test depends on particular mock response pattern, call renderPage with the handlers you want to override:

describe('Todos Page', () => {
  const renderPage = testRenderer(<Todos />)

  it('displays "No Items" when there is no todo', async () => {
    renderPage(
      // overrides existing GetTodosDocument query.
      graphql.query(GetTodosDocument, (req, res, ctx) =>
        res.once(
          ctx.data({
            todosByCurrentUser: [],
          })
        )
      )
    )
    const target = await screen.findByText('No Items')
    expect(target).toBeInTheDocument()
  })

  it('displays "completed" on the todo when fetched todo is completed', async () => {
    renderPage(
      // overrides existing GetTodosDocument query.
      graphql.query(GetTodosDocument, (req, res, ctx) =>
        res.once(
          ctx.data({
            todosByCurrentUser: [todoFactory({completed: true})],
          })
        )
      )
    )
    const todo = await screen.findByTestId('todo')
    expect(within(todo).getByText('completed')).toBeInTheDocument()
  })
})
Enter fullscreen mode Exit fullscreen mode

mutation test

You can test mutation call by define interceptor mock function and pass variables in your msw handler:

describe('Todos Page', () => {
  const renderPage = testRenderer(<Todos />)

  it('should create new Todo', async () => {
    const mutationInterceptor = jest.fn()
    renderPage(
      graphql.mutation(SaveTodoDocument, (req, res, ctx) => {
        mutationInterceptor(req.variables) // pass the variables here
        return res.once(
          ctx.data({
            saveTodo: {
              __typename: 'Todo',
              id: '1',
            },
          })
        )
      })
    )

    const input = screen.getByLabelText('title')
    fireEvent.change(input, { target: { value: 'test' } })
    const submitButton = screen.getByText('Submit')
    fireEvent.click(submitButton)

    await waitFor(() =>
      expect(mutationInterceptor).toHaveBeenCalledWith({
        todo: {
          title: 'test',
        },
      } as SaveTodoMutationVariables)
    )
  })
})
Enter fullscreen mode Exit fullscreen mode

mock factory pattern

In the example code above, I used todoFactory() function. Explained well in this post, but in a nutshell, it is a helper function that produces mock data easily and flexibly.

let nextFactoryIds: Record<string, number> = {}

export function resetFactoryIds() {
  nextFactoryIds = {}
}

export function nextFactoryId(objectName: string): string {
  const nextId = nextFactoryIds[objectName] || 1
  nextFactoryIds[objectName] = nextId + 1
  return String(nextId)
}

function todoFactory(options?: Partial<Todo>): Todo {
  return {
    __typename: 'Todo',
    id: nextFactoryId('Todo'),
    title: 'test todo',
    completed: false,
    ...options,
  }
}

// usage
todoFactory()
todoFactory({completed: true})
Enter fullscreen mode Exit fullscreen mode

I'm implementing auto incremented id here but it's optional. If you want, don't forget to reset incremented ids in afterEach.

Summary

  • Avoid mock your Graphql Client or Provider.
  • MSW is a good fit for mocking graphql response.
  • Mock factory pattern might help you define mock data.

You can find entire code example in my boilerplate repo:
https://github.com/taneba/fullstack-graphql-app/blob/main/frontend/src/pages/todos/index.test.tsx

I hope you enjoyed and this article helps you in some way. Happy coding!

Discussion (0)