There are pretty good articles on using msw for graphql / rest unit testing for react out there. And this is my implementation with a protip at the end π so don't miss a detail π
TL'DR If you want to skip this biggg reading, here is the source code
This is a no-explanation article. simply copy paste the code and it will work.
This implementation has 3 stages.
Stage 1 => Implementing graphql
Stage 2 => Writing unit tests with msw
Stage 3 => Using msw as development server
As ususal let's start with Stage 1.
Stage 1: Implementing graphql
Step - 1: App scaffolding
Create a react app using CRA and typescript, assuming you installed cra globally:
run yarn create react-app my-app --template typescript
Step - 2: Folder structure
This is my folder structure
.
βββ src/
β βββ api/
β β βββ gql/
β β β βββ interceptors/
β β β β βββ interceptor.ts
β β β β βββ utils.ts
β β β β βββ index.ts
β β β βββ models/
β β β β βββ users.model.ts
β β β β βββ index.ts
β β β βββ queries/
β β β β βββ users.ts
β β β β βββ index.ts
β β β βββ requests/
β β β βββ users.ts
β β β βββ index.ts
β β βββ mockhandler/
β β β βββ queries
β β β βββ index.ts
β β βββ testUtils/
β β βββ index.tsx
β βββ Users.tsx
β βββ Users.spec.tsx
β βββ App.tsx
β βββ App.spec.tsx
β βββ index.tsx
β βββ jest.setup.ts
βββ jest.config.ts
βββ setupApiTests.ts
βββ global.d.ts
βββ package.json
Step - 3: Packages
let's add following packages
yarn add @tanstack/react-query graphql-request @testing-library/jest-dom @testing-library/react @testing-library/user-event @testing-library/jest-dom @types/jest ts-jest ts-node msw identity-obj-proxy
Step - 4: Setting up Jest
Add the following to
- jest.config.ts
//jest.config.ts
export default {
displayName: "react-graphql-unittesting",
testEnvironment: "jsdom",
transform: {
"^.+\\.tsx?$": "ts-jest",
},
setupFilesAfterEnv: [
"<rootDir>/src/jest.setup.ts",
"<rootDir>/setupApiTests.ts",
],
moduleNameMapper: {
'^.+\\.(css|less)$': 'identity-obj-proxy'
},
collectCoverageFrom: [
'src/**/*{js,jsx,ts,tsx}',
'!src/**/index.ts',
'!<rootDir>/node_modules/',
],
coverageReporters: ["clover", "json", "lcov", "text", "html"],
};
- setupApiTests.ts
//setupApiTests.ts
const { setupServer } = require('msw/node');
const { handler } = require('./src/api/mockHandler');
const server = setupServer(...handler);
// 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());
export {};
- jest.setup.ts
//jest.setup.ts
import '@testing-library/jest-dom';
jest.setTimeout(30000);
Step - 4: Implementing sample graphql api
Let's use graphqlZero for sample graphql api.
a. lets add interceptor
// interceptor/utils.ts
enum QueryType {
QUERY = 'query',
MUTATION = 'mutation'
}
const extractNameFromQuery = (query: string) => {
const keywordIndex =
query.indexOf(QueryType.QUERY) !== -1
? query.indexOf(QueryType.QUERY) + QueryType.QUERY.length
: query.indexOf(QueryType.MUTATION) + QueryType.MUTATION.length;
return query.substring(keywordIndex, query.indexOf('(')).replace(/ /g, '');
};
export { extractNameFromQuery };
// interceptor/interceptor.ts
import { GraphQLClient } from 'graphql-request';
import { extractNameFromQuery } from './utils';
const getGraphQLClient = (
_queryName: string,
optionalHeader?: Record<string, string | number | boolean>
): GraphQLClient => {
let globalHeaders = {};
if (optionalHeader) {
globalHeaders = {
...optionalHeader,
...globalHeaders
};
}
return new GraphQLClient('https://graphqlzero.almansi.me/api', {
headers: { ...globalHeaders } as Record<string, string>
});
};
const GQLInteraction = async <T,>(
schema: string,
variables?: Record<string, string[] | number | number[] | unknown> | undefined,
optionalHeader: Record<string, string | number | boolean> = {}
): Promise<T> => {
try {
const queryDescription = extractNameFromQuery(schema);
const client = getGraphQLClient(queryDescription, { ...optionalHeader });
return await client.request(schema, variables);
} catch (err) {
console.log('error', err);
throw err;
}
};
export { GQLInteraction };
// interceptor/index.ts
export * from './interceptor';
b. lets add the query
// queries/users.ts
export const getUser = `
query user($id: ID!){
user(id: $id) {
id
username
email
address {
geo {
lat
lng
}
}
}
}
`;
// queries/index.ts
export * from './users';
c. lets add the response type
// models/users.model.ts
export interface User {
id: string;
username: string;
email: string;
address: {
geo: {
lat: string;
lng: string;
};
};
}
export interface UserResponse {
user: User
}
// models/index.ts
export * from "./users.model";
d. lets create a request to fetch the user
// requests/users.ts
import { UseQueryResult, useQuery } from "@tanstack/react-query";
import { GQLInteraction } from "../interceptors";
import { User, UserResponse } from "../models";
import { getUser } from "../queries";
export const useGetUser = (userid: string): UseQueryResult<User, unknown> => {
const options = {
refetchOnWindowFocus: false,
retry: 0,
select: (response: UserResponse): User => {
return response.user;
},
};
return useQuery(
["user"],
() => GQLInteraction<UserResponse>(getUser, { id: userid }),
options
);
};
// requests/index.ts
export * from './users';
Step - 5: Consuming api in component
a. consume request in Users component
// Users.tsx
import { useGetUser } from "./api/gql/requests";
export function Users() {
const { data, isFetching } = useGetUser("1");
return !isFetching && data ? (
<h1 data-testid="username">{data.username}</h1>
) : (
<div data-testid="loading">loading</div>
);
}
b. update App component as below
// App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import "./App.css";
import { Users } from "./Users";
function App() {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
}
export default App;
Finally we integrated graphql in our react application.
Oops.. App.spec.tsx is failing. lets modify it as below:
import { render, screen } from "@testing-library/react";
import App from "./App";
describe("App", () => {
it("should renders learn react link", () => {
render(<App />);
const loaderElement = screen.getByTestId("loading");
expect(loaderElement).toBeInTheDocument();
});
});
Huff now looks good.
Now it's time for implementing Stage 2.
Stage 2: Writing unit tests with msw
Step - 1: implementing mock handler.
In stage 1 when we run unit tests, jest didn't get terminated properly. this is because our component tries to make api call as if in real browser. But it didn't get any response hence jest didn't get terminated properly. To fix this lets implement the mock handlers that provide mock api response.
// mockhandler/queries/users.ts
import { graphql } from "msw";
export const UsersQueries = [
graphql.query("user", (_req, res, ctx) => {
return res(
ctx.data({
user: {
id: "1",
username: "Antonette",
email: "Shanna@melissa.tv",
address: {
geo: {
lat: -43.9509,
lng: -34.4618,
},
},
},
})
);
}),
];
// mockhandler/queries/index.ts
export * from './users';
// mockhandler/index.ts
import { UsersQueries } from "./queries";
const handler = [...UsersQueries];
export { handler };
That's it. now if we run tests again, the msw provides mock api response to user
query and jest will terminate properly.
As usual there's scope for improvement.
Lets refactor this a little bit.
// testUtils/index.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ComponentType, ReactNode } from "react";
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
export function createWrapper(node: ReactNode): ComponentType {
const testQueryClient = createTestQueryClient();
return function MockComponent() {
return (
<QueryClientProvider client={testQueryClient}>{node}</QueryClientProvider>
);
};
}
Lets update Users component
// Users.tsx
import { useGetUser } from "./api/gql/requests";
export function Users() {
const { data, isFetching } = useGetUser("1");
return (
<div data-testid="users-component">
{!isFetching && data ? (
<h1 data-testid="username">{data.username}</h1>
) : (
<div data-testid="loading">loading</div>
)}
</div>
);
}
Let's update the App.spec.ts
// App.spec.ts
import { render, screen } from "@testing-library/react";
import App from "./App";
describe("App", () => {
it("should renders learn react link", () => {
render(<App />);
const usersComponent = screen.getByTestId("users-component");
expect(usersComponent).toBeInTheDocument();
});
});
Now let's create a spec file for users component.
// Users.spec.ts
import { render, screen, waitFor } from "@testing-library/react";
import { Users } from "./Users";
import { createWrapper } from "./api/testUtils";
describe("Users", () => {
const Component = createWrapper(<Users />);
it("should render users component", () => {
render(<Component />);
expect(screen.getByTestId("users-component")).toBeInTheDocument();
});
it("should display user name from api", async () => {
render(<Component />);
await waitFor(() => {
expect(screen.getByText(/Antonette/i)).toBeInTheDocument();
});
});
});
The createWrapper
function will help to wrap any individual component which consume graphql in unit tests.
Awesome. all the happy flow is done. but what about error scenerio??
Now it's time for protip
Step - 2: Protip
let's update the setupApiTests file
// setupApiTests.ts
const { setupServer } = require("msw/node");
const { handler } = require("./src/api/mockHandler");
const server = setupServer(...handler);
global.mswServer = 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());
export {};
create global.d.ts
in root and add the following:
declare module globalThis {
var mswServer: SetupServer;
}
update tsconfig include property:
// tsconfig.json
{
...
"include": ["src", "global.d.ts"]
}
finally update testutils as below:
// testUtils/index.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { graphql } from "msw";
import { SetupServer } from "msw/node";
import { ComponentType, ReactNode } from "react";
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
export function createWrapper(node: ReactNode): ComponentType {
const testQueryClient = createTestQueryClient();
return function MockComponent() {
return (
<QueryClientProvider client={testQueryClient}>{node}</QueryClientProvider>
);
};
}
export function mockApiResponse(
queryName: string,
response: Record<string, unknown> | null,
throwError = false
) {
(global.mswServer as SetupServer).use(
graphql.query(queryName, (_req, res, ctx) => {
if (throwError) {
return res(ctx.errors([]));
}
return res(ctx.data(response as Record<string, unknown>));
})
);
}
Now it's time to test -ve scenerio in users component:
// Users.tsx
import { useGetUser } from "./api/gql/requests";
export function Users() {
const { data, isFetching, error } = useGetUser("1");
return (
<div data-testid="users-component">
{!isFetching && data ? (
<h1 data-testid="username">{data.username}</h1>
) : (
<div data-testid="loading">loading</div>
)}
{error ? (
<div data-testid="api-error">Oops something went wrong</div>
) : null}
</div>
);
}
// Users.spec.tsx
import { render, screen, waitFor } from "@testing-library/react";
import { Users } from "./Users";
import { createWrapper, mockApiResponse } from "./api/testUtils";
describe("Users", () => {
const Component = createWrapper(<Users />);
it("should render users component", () => {
render(<Component />);
expect(screen.getByTestId("users-component")).toBeInTheDocument();
});
it("should display user name from api", async () => {
render(<Component />);
await waitFor(() => {
expect(screen.getByText(/Antonette/i)).toBeInTheDocument();
});
});
it("should display api-error placeholder when api throws error", async () => {
mockApiResponse("user", null, true);
render(<Component />);
await waitFor(() => {
expect(screen.getByTestId("api-error")).toBeInTheDocument();
});
});
});
And that's how we tested api failure case too.
That's a lot of copy paste but i promise this surely helps you alot.
Now its time for final stage.
Stage 3: Using msw as development server
With very minimal changes we can convert the till now msw setup as development server as well.
Let's see how to do that.
In behind the screens, msw rely on serviceWorker in application public folder to serve mock api responses (check here to know more about this). To setup this service worker let's run the below command (assuming you have msw installed globally. else move the below command to a script in package json and run it).
msw init public --save
This command essentially creates the service worker for us in cra public
folder.
After this lets tweak the index.tsx
file as below
// index.tsx
...
import { setupWorker } from 'msw';
import { handler } from './api/mockhandler';
if(process.env.NODE_ENV === 'development') {
setupWorker(...handler).start();
}
...
That's it. The mock server is up and running. To test this, turn off internet and run the app you will see the data displayed from msw π
The process is same for graphql.mutation
too.
Are you using rest instead of graphql, no worries. in mockhandler instead of using graphql.query
use rest
from msw. The official msw docs are good enough to understand using rest with msw.
And That's the end of this article.
Thanks,
Kiran π
Top comments (2)
Thanks!!! it was exactly what I was looking for.
Glad it helped you :)