Skip to content

Demonstrating approaches for testing React components that integrate with Apollo Client for GraphQL operations.

License

doytch/rtl-apollo-testing

Repository files navigation

React Testing Library + Apollo Client Testing

This project demonstrates a comprehensive testing strategy for React components that use Apollo Client for GraphQL operations. The approach focuses on testing components in isolation while ensuring full coverage of network interactions.

The reader should already be familiar with React Testing Library and Apollo idioms. For simplicity, I've ignored Apollo type generation (I assume you have that sorted) and define my types by hand.

All code in this readme is in the project and runnable. I recommend exercising it and seeing how different modifications to the components will make the tests red.

Table of Contents

Components Overview

Both of the the components here do a decent amount of things. In real-world scenarios, doing network-access and DOM rendering might be a bit too much if we're playing by SOLID-ish principles and trying to write maintainable tests.

Those scenarios could split each of these components into smart/dumb components, or use more presentation components, etc. The Dear Reader is more than capable of imagining what that would look like.

Users Component

The Users component is a component that:

  • Fetches and displays a list of users
  • Provides a button to open a user creation dialog
  • Handles loading and error states
  • Refetches data after successful user creation

CreateUserDialog Component

The CreateUserDialog component is a component that:

  • Manages its own form state
  • Handles the CREATE_USER mutation
  • Provides success/error callbacks to parent components

Drawing Seams with Mocking

DO NOT mock every child component. This goes against the Testing Library principle of "the more your tests resemble the way your software is used, the more confidence they can give you." However, when a child component becomes complex enough to have its own substantial responsibilities (network calls, complex state management, etc.), it becomes a good candidate for mocking because:

  • It creates a natural seam between different areas of responsibility
  • It allows you to focus each test on a specific concern
  • It keeps tests fast and maintainable

The CreateUserDialog component (especially a realistic implementation) is complex and has its own network responsibilities. Rather than testing the entire integration, we draw a seam between the components by mocking the dialog.

Mock Implementation

In Users.test.tsx, we mock the CreateUserDialog component:

vitest.mock("./CreateUserDialog", () => ({
  default: (props: ComponentProps<typeof CreateUserDialog>) => {
    if (!props.isOpen) return null;

    return (
      <dialog open={props.isOpen} onClose={props.onClose}>
        <button
          data-testid="mock-create-user-button"
          onClick={() => {
            props.onSuccess({
              id: "1",
              name: "John Doe",
              email: "[email protected]",
              role: "admin",
            });
            props.onClose();
          }}
        >
          Create
        </button>
      </dialog>
    );
  },
}));

We delegate all implementation details of the dialog (eg, does onSuccess and onClose get called at the right time?) to its own tests.

This simple mock:

  • Simulates the dialog's open/close behavior
  • Provides a simple button that triggers the success callback
  • Uses test IDs to clearly indicate it's a mock component
  • Allows us to test the parent component's integration logic without the complexity of the real dialog.

Testing Query Execution

We use Apollo Client's MockedProvider to verify that the correct queries are executed. For convenience, we're importing the query from the component under test, but a hardliner could write it out explicitly.

test("renders users if there are users", async () => {
  render(
    <MockedProvider
      mocks={[
        {
          request: { query: USERS_QUERY },
          result: { data: { users: [{ id: "1", name: "John Doe" }] } },
        },
      ]}
    >
      <Users />
    </MockedProvider>
  );

  expect(await screen.findByText("John Doe")).toBeVisible();
});

Testing Mutation Execution

For the CreateUserDialog component, we test that mutations are called with the correct variables. A key technique here is using the function form of MockedResponse['result']:

test("calls the createUser mutation when the form is submitted", async () => {
  // Set up a mock mutation result. This uses the function form of MockedResponse['result']
  // that returns a mock result. This function is called by Apollo on-demand only when
  // the response is needed. This allows us to assert that a specific query/mutation
  // was actually called and the response was requested.
  const onMutationResult = vitest.fn(() => ({
    data: {
      createUser: {
        id: "1",
        name: "John Doe",
        email: "[email protected]",
        role: "admin",
      },
    },
  }));

  render(
    <MockedProvider
      mocks={[
        {
          request: {
            query: CREATE_USER_MUTATION,
            variables: {
              name: "John Doe",
              email: "[email protected]",
              role: "admin",
            },
          },
          result: onMutationResult, // Function form, not a plain object
        },
      ]}
    >
      <CreateUserDialog />
    </MockedProvider>
  );

  // ...

  await waitFor(() => expect(onMutationResult).toHaveBeenCalled());
});

Why Use Function Form?

The function form of MockedResponse['result'] is crucial because the function is only called when Apollo actually needs the response, not when the mock is defined. Combined with the appropriate assert (toHaveBeenCalled), this gives us a guarantee that we went "onto the wire" and called that specific mutation with those specific variables.

Testing Error Scenarios

We also test error handling by providing error responses in our mocks:

test("calls onError when user creation fails", async () => {
  const onError = vitest.fn();

  render(
    <MockedProvider
      mocks={[
        {
          request: {
            query: CREATE_USER_MUTATION,
            variables: {
              /* ... */
            },
          },
          error: new Error("User creation failed"),
        },
      ]}
    >
      <CreateUserDialog onError={onError} />
    </MockedProvider>
  );

  // Submit form and verify error callback
  await userEvent.click(screen.getByRole("button", { name: "Create" }));
  await waitFor(() =>
    expect(onError).toHaveBeenCalledWith(new Error("User creation failed"))
  );
});

Testing Data Refetching

Refetch Strategy

The Users component refetches data after successful user creation by calling the refetch() function from Apollo's useQuery hook:

<CreateUserDialog
  isOpen={isCreateUserDialogOpen}
  onClose={() => setIsCreateUserDialogOpen(false)}
  onSuccess={() => {
    setIsCreateUserDialogOpen(false);
    refetch(); // This triggers a new query execution
  }}
  onError={(error) => {
    console.error(error);
  }}
/>

You could also use refetch sets if it makes sense in your scenario and this testing approach will work the same.

However, if the dialog here was doing direct Apollo cache modification, this strategy would not work. Testing cache modification becomes more complicated; reliant on Apollo; and impossible to test across dependent components like this. This is why I tend to avoid that strategy and stick with simple, boring refetch() if possible.

Testing Refetch Behavior

We test this by providing multiple mock responses for the same query:

test("refetches users after creating a user", async () => {
  render(
    <MockedProvider
      mocks={[
        // Initial query returns no users
        {
          request: { query: USERS_QUERY },
          result: { data: { users: [] } },
        },
        // Refetch query returns a user
        {
          request: { query: USERS_QUERY },
          result: { data: { users: [{ id: "1", name: "John Doe" }] } },
        },
      ]}
    >
      <Users />
    </MockedProvider>
  );

  // Initially shows "No users"
  expect(await screen.findByText("No users")).toBeVisible();

  // Open dialog and create user
  await userEvent.click(screen.getByRole("button", { name: "Create User" }));
  await userEvent.click(screen.getByTestId("mock-create-user-button"));

  // Verify refetch occurred and new data is displayed
  expect(await screen.findByText("John Doe")).toBeVisible();
});

This approach ensures that:

  1. The initial query executes correctly
  2. The refetch is triggered after user creation
  3. The UI updates with the new data
  4. The dialog closes properly

About

Demonstrating approaches for testing React components that integrate with Apollo Client for GraphQL operations.

Topics

Resources

License

Stars

Watchers

Forks