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...');
});
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>
);
});
Well, what's wrong with mocking?
- It's tied to particular GraphQL Client. Tests will be broken if you change the client library one to another.
- 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
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!
}
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>
);
};
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
})
)
),
]
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)
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())
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>)
}
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)
})
})
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()
})
})
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)
)
})
})
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})
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!
Top comments (1)
A great introduction into efficient GraphQL testing. Well done, Yoshihiro!
I can also highly recommend tools like @mswjs/data to model your fixtures instead of using manual mock factories. It comes with mind-blowing TypeScript support and built-in querying client, making your experience with test fixtures unrivaled.