Bright ideas and techniques for building with Convex.
Profile image

Testing React Components with Convex

A screenshot of Jest test results

Oftentimes during testing we want to mock out our backend so we can unit test our UI components without talking to our actual server code.

In this article, we’ll explore options for testing React components that call Convex React hooks using mocking and dependency injection. To do this, I’ve written a sample TypeScript React app using the Vitest testing framework. The patterns presented in this post are also applicable to JavaScript apps and other frameworks.

Here, we have a React component that renders a counter value stored in Convex, and provides a button for incrementing the counter value by one.

// See the full file here:
// https://github.com/get-convex/convex-helpers/blob/main/src/components/Counter.tsx

const Counter = () => {
  const counter =
    useQuery(api.counter.getCounter, { counterName: "clicks" }) ?? 0;    
  const incrementByOne = useCallback(
    () => increment({ counterName: "clicks", increment: 1 }),
    [increment]
  );

  return (
    <div>
      <p>
        {"Here's the counter:"} {counter}
      </p>
      <button onClick={incrementByOne}>Add One!</button>
    </div>
  );
};

export default Counter;
Convex function details...
// See the full file here:
// https://github.com/get-convex/convex-helpers/blob/main/convex/counter.ts

import { query } from "./_generated/server";
import { mutation } from "./_generated/server";

const getCounter = query(
  async ({ db }, { counterName }: { counterName: string }): Promise<number> => {
    const counterDoc = await db
      .query("counter_table")
      .filter((q) => q.eq(q.field("name"), counterName))
      .first();
    return counterDoc === null ? 0 : counterDoc.counter;
  }
);

const incrementCounter = mutation(
  async (
    { db },
    { counterName, increment }: { counterName: string; increment: number }
  ) => {
    const counterDoc = await db
      .query("counter_table")
      .filter((q) => q.eq(q.field("name"), counterName))
      .first();
    if (counterDoc === null) {
      await db.insert("counter_table", {
        name: counterName,
        counter: increment,
      });
    } else {
      counterDoc.counter += increment;
      await db.replace(counterDoc._id, counterDoc);
    }
  }
);

export { getCounter, incrementCounter };

Let’s write a test to make sure this component renders the value returned from the query above. Later, we can add more tests, such as ensuring that clicking on the button calls the desired mutation (as seen in the full examples linked in the snippets later in this article).

import Counter from "./Counter";
import { render } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";

const setup = () => render(<Counter />);

describe("Counter", () => {
  it("renders the counter", async () => {
    const { getByText } = setup();
    expect(getByText("Here's the counter: 0")).not.toBeNull();
  });
});

But wait! This test doesn’t work yet. Since the Counter component invokes Convex queries and mutations, we need some mechanism to replace our Convex functions with fake implementations. If we try to run this test file without doing so, we’ll get an error with the Convex library complaining that a Convex client has not been provided.

Method 1: Mocking the convex import

This method takes advantage of Vitest’s mock to replace the internal useQuery and useMutation implementations with versions that call our fake methods defined in the previous code snippet.

// See the full file here:
// https://github.com/get-convex/convex-helpers/blob/main/src/components/Counter.mock.test.tsx

import * as convexReact from "convex/react";

vi.mock("convex/react", async () => {
  const actual = await vi.importActual<typeof convexReact>("convex/react");

  return {
    ...actual,

    // Not a typo! useQuery in code calls useQueryGeneric under the hood!
    useQueryGeneric: (queryName: string, args: Record<string, any>) => {
      if (queryName !== "counter:getCounter") {
        throw new Error("Unexpected query call!");
      }
      return getCounter(args as any);
    },
    useMutationGeneric: (mutationName: string) => {
      if (mutationName !== "counter:incrementCounter") {
        throw new Error("Unexpected mutation call!");
      }
      return incrementCounterMock;
    },
  };
});

This method requires few dependencies, but may not work in test environments that don’t provide functionality for mocking external libraries. Read on for a cleaner solution!

Method 2: Using dependency injection to replace the Convex client

Alternatively, we can use dependency injection (AKA having dependencies be arguments to a function, and passing in a different dependency in test vs. our real codebase) to convince the Convex library it’s connected to a real deployment.

We’ve prepared a version of the ConvexReactClient for testing purposes. The code and TypeScript bindings for ConvexReactClientFake can be found here.

// See the full file here:
// https://github.com/get-convex/convex-helpers/blob/main/src/components/Counter.injection.test.tsx

// Initialize the Convex mock client
const fakeClient = new ConvexReactClientFake<API>({
  queries: {
    // Replace getCounter with a simple function that returns a global value
    "counter:getCounter": getCounter,
  },
  mutations: {
    // Replace incrementCounter with a mocked function that can be spied on in tests
    "counter:incrementCounter": incrementCounterMock,
  },
});

// The setup function is different here -- we wrap the Counter in a ConvexProvider
const setup = () =>
  render(
    <ConvexProvider client={fakeClient}>
      <Counter />
    </ConvexProvider>
  );

In summary, you can use one of the patterns above to write unit tests for React components that call Convex functions.

Have a use case that isn’t covered here? Reach out to us on the Convex community discord.

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started