DEV Community

Kiran Mantha
Kiran Mantha

Posted on • Edited on

Implementing and Unit testing Graphql in React using msw

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
Enter fullscreen mode Exit fullscreen mode

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"],
};
Enter fullscreen mode Exit fullscreen mode
  • 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 {};

Enter fullscreen mode Exit fullscreen mode
  • jest.setup.ts
//jest.setup.ts

import '@testing-library/jest-dom';
jest.setTimeout(30000);
Enter fullscreen mode Exit fullscreen mode

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';

Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
  });
});

Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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>
    );
  };
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
  });
});

Enter fullscreen mode Exit fullscreen mode

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();
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

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 {};

Enter fullscreen mode Exit fullscreen mode

create global.d.ts in root and add the following:

declare module globalThis {
  var mswServer: SetupServer;
}
Enter fullscreen mode Exit fullscreen mode

update tsconfig include property:

// tsconfig.json
{
   ...
   "include": ["src", "global.d.ts"]
}
Enter fullscreen mode Exit fullscreen mode

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>));
    })
  );
}

Enter fullscreen mode Exit fullscreen mode

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();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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();   
}

...
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
jhaeberli profile image
Julian Haeberli

Thanks!!! it was exactly what I was looking for.

Collapse
 
kiranmantha profile image
Kiran Mantha • Edited

Glad it helped you :)