Testing Convex Backend: Reactive Queries, Mutations, and CI Integration

Testing Convex Backend: Reactive Queries, Mutations, and CI Integration

Convex has become a popular choice for full-stack TypeScript applications that need reactive, real-time backends. Its architecture—where queries automatically re-run when underlying data changes—is powerful but also introduces unique testing challenges. How do you verify that a query stays in sync? How do you test a mutation and confirm the resulting reactive update? This guide covers everything you need to build a reliable test suite for a Convex-powered application.

Why Convex Testing Requires a Different Approach

Traditional backend testing follows a simple pattern: call a function, check the return value, assert on side effects. Convex complicates this because the real value proposition is reactivity. A query doesn't just return data once—it subscribes to data changes and pushes updates to subscribers. Testing this reactivity requires tools that can simulate the Convex runtime and observe state transitions over time.

Convex provides two layers worth testing:

  1. Server-side functions — queries, mutations, and actions defined in your convex/ directory
  2. Client-side integration — React components using useQuery, useMutation, and useAction hooks

Both layers need attention, and the testing strategies differ significantly.

Setting Up the Test Environment

Start by installing the testing dependencies:

npm install --save-dev vitest @testing-library/react @testing-library/jest-dom
npm install --save-dev convex-test

The convex-test package provides an in-memory Convex environment that mirrors the real runtime without requiring a deployed backend. Configure Vitest to work with Convex's module system:

// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    globals: true,
  },
  resolve: {
    alias: {
      "@": "/src",
    },
  },
});
// src/test/setup.ts
import "@testing-library/jest-dom";

Testing Server-Side Functions with convex-test

The convex-test library creates a mock Convex environment that runs your actual function code. This is the most valuable layer to test because it covers your business logic without any UI concerns.

Testing Queries

// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

export const listTasks = query({
  args: { projectId: v.id("projects") },
  handler: async (ctx, { projectId }) => {
    return await ctx.db
      .query("tasks")
      .withIndex("by_project", (q) => q.eq("projectId", projectId))
      .order("desc")
      .collect();
  },
});

export const createTask = mutation({
  args: {
    projectId: v.id("projects"),
    title: v.string(),
    priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
  },
  handler: async (ctx, args) => {
    return await ctx.db.insert("tasks", {
      ...args,
      completed: false,
      createdAt: Date.now(),
    });
  },
});
// convex/tasks.test.ts
import { convexTest } from "convex-test";
import { expect, test, describe } from "vitest";
import schema from "./schema";
import { api } from "./_generated/api";

describe("tasks queries", () => {
  test("listTasks returns tasks for a project", async () => {
    const t = convexTest(schema);

    // Seed a project and tasks
    const projectId = await t.run(async (ctx) => {
      return await ctx.db.insert("projects", { name: "Test Project" });
    });

    await t.mutation(api.tasks.createTask, {
      projectId,
      title: "First task",
      priority: "high",
    });

    await t.mutation(api.tasks.createTask, {
      projectId,
      title: "Second task",
      priority: "low",
    });

    const tasks = await t.query(api.tasks.listTasks, { projectId });

    expect(tasks).toHaveLength(2);
    expect(tasks[0].title).toBe("Second task"); // ordered desc by creation
    expect(tasks[1].title).toBe("First task");
  });

  test("listTasks does not return tasks from other projects", async () => {
    const t = convexTest(schema);

    const projectA = await t.run(async (ctx) => {
      return await ctx.db.insert("projects", { name: "Project A" });
    });

    const projectB = await t.run(async (ctx) => {
      return await ctx.db.insert("projects", { name: "Project B" });
    });

    await t.mutation(api.tasks.createTask, {
      projectId: projectA,
      title: "Task for A",
      priority: "medium",
    });

    const tasksForB = await t.query(api.tasks.listTasks, {
      projectId: projectB,
    });

    expect(tasksForB).toHaveLength(0);
  });
});

Testing Mutations with Side Effects

Mutations often have side effects beyond simple database writes—sending emails, updating counters, or scheduling follow-up actions. Test these explicitly:

// convex/projects.ts
import { mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";

export const completeTask = mutation({
  args: { taskId: v.id("tasks") },
  handler: async (ctx, { taskId }) => {
    const task = await ctx.db.get(taskId);
    if (!task) throw new Error("Task not found");
    if (task.completed) throw new Error("Task already completed");

    await ctx.db.patch(taskId, {
      completed: true,
      completedAt: Date.now(),
    });

    // Schedule a notification
    await ctx.scheduler.runAfter(0, internal.notifications.taskCompleted, {
      taskId,
      projectId: task.projectId,
    });

    return taskId;
  },
});
// convex/projects.test.ts
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "./schema";
import { api, internal } from "./_generated/api";

test("completeTask marks task done and schedules notification", async () => {
  const t = convexTest(schema);

  const projectId = await t.run(async (ctx) => {
    return await ctx.db.insert("projects", { name: "Test" });
  });

  const taskId = await t.mutation(api.tasks.createTask, {
    projectId,
    title: "Important task",
    priority: "high",
  });

  await t.mutation(api.tasks.completeTask, { taskId });

  const task = await t.run(async (ctx) => ctx.db.get(taskId));

  expect(task?.completed).toBe(true);
  expect(task?.completedAt).toBeDefined();

  // Verify scheduled function was queued
  await t.finishInProgressScheduledFunctions();

  // Check that the notification was processed
  const notifications = await t.run(async (ctx) => {
    return await ctx.db
      .query("notifications")
      .filter((q) => q.eq(q.field("taskId"), taskId))
      .collect();
  });

  expect(notifications).toHaveLength(1);
});

test("completeTask throws when task does not exist", async () => {
  const t = convexTest(schema);

  const fakeId = "jd7f9sd8f7s9df" as any;

  await expect(
    t.mutation(api.tasks.completeTask, { taskId: fakeId })
  ).rejects.toThrow("Task not found");
});

Testing Reactive Queries in React Components

The server-side tests cover your logic, but you also need to verify that your React components correctly subscribe to and display reactive data. Use a mock Convex provider for this.

// src/test/MockConvexProvider.tsx
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";

// Create a test client pointing to a local or mock backend
export function MockConvexProvider({ children }: { children: ReactNode }) {
  const client = new ConvexReactClient(
    process.env.VITE_CONVEX_URL || "https://test.convex.cloud"
  );

  return <ConvexProvider client={client}>{children}</ConvexProvider>;
}

For more isolated component tests, mock the Convex hooks directly:

// src/components/TaskList.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, expect, test, describe } from "vitest";
import { TaskList } from "./TaskList";

// Mock convex/react hooks
vi.mock("convex/react", () => ({
  useQuery: vi.fn(),
  useMutation: vi.fn(),
  useConvexAuth: vi.fn(() => ({ isAuthenticated: true, isLoading: false })),
}));

import { useQuery, useMutation } from "convex/react";

describe("TaskList component", () => {
  test("renders tasks from reactive query", async () => {
    const mockTasks = [
      { _id: "1", title: "Write tests", priority: "high", completed: false },
      { _id: "2", title: "Ship feature", priority: "medium", completed: false },
    ];

    vi.mocked(useQuery).mockReturnValue(mockTasks);
    vi.mocked(useMutation).mockReturnValue(vi.fn());

    render(<TaskList projectId="project-1" />);

    expect(screen.getByText("Write tests")).toBeInTheDocument();
    expect(screen.getByText("Ship feature")).toBeInTheDocument();
  });

  test("shows loading state when query returns undefined", () => {
    vi.mocked(useQuery).mockReturnValue(undefined);

    render(<TaskList projectId="project-1" />);

    expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
  });

  test("calls mutation when task is completed", async () => {
    const user = userEvent.setup();
    const mockCompleteTask = vi.fn().mockResolvedValue(undefined);

    vi.mocked(useQuery).mockReturnValue([
      { _id: "1", title: "Test task", priority: "low", completed: false },
    ]);
    vi.mocked(useMutation).mockReturnValue(mockCompleteTask);

    render(<TaskList projectId="project-1" />);

    const completeButton = screen.getByRole("button", { name: /complete/i });
    await user.click(completeButton);

    expect(mockCompleteTask).toHaveBeenCalledWith({ taskId: "1" });
  });
});

Testing Convex Actions

Actions are the escape hatch in Convex—they run outside transactions and can call third-party APIs. Testing them requires mocking external services:

// convex/actions/stripe.ts
import { action } from "../_generated/server";
import { v } from "convex/values";
import Stripe from "stripe";

export const createCheckoutSession = action({
  args: { priceId: v.string(), userId: v.string() },
  handler: async (ctx, { priceId, userId }) => {
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

    const session = await stripe.checkout.sessions.create({
      mode: "subscription",
      payment_method_types: ["card"],
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${process.env.SITE_URL}/success`,
      cancel_url: `${process.env.SITE_URL}/cancel`,
      metadata: { userId },
    });

    return { url: session.url };
  },
});
// convex/actions/stripe.test.ts
import { convexTest } from "convex-test";
import { expect, test, vi, beforeEach } from "vitest";
import schema from "../schema";
import { api } from "../_generated/api";

vi.mock("stripe", () => {
  return {
    default: vi.fn().mockImplementation(() => ({
      checkout: {
        sessions: {
          create: vi.fn().mockResolvedValue({
            url: "https://checkout.stripe.com/test-session",
            id: "cs_test_123",
          }),
        },
      },
    })),
  };
});

test("createCheckoutSession returns a Stripe URL", async () => {
  const t = convexTest(schema);

  const result = await t.action(api.actions.stripe.createCheckoutSession, {
    priceId: "price_test_123",
    userId: "user_456",
  });

  expect(result.url).toBe("https://checkout.stripe.com/test-session");
});

CI Integration

Convex tests run entirely in-process with no external dependencies, making them ideal for CI pipelines:

# .github/workflows/test.yml
name: Test Convex Backend

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run Convex function tests
        run: npx vitest run --reporter=verbose

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info

For end-to-end tests that validate the full reactive pipeline against a real Convex deployment, tools like HelpMeTest can generate and run automated browser tests against your staging environment. HelpMeTest's AI-powered test generation can analyze your Convex-backed UI and create tests that verify real-time updates propagate correctly—for instance, confirming that a mutation in one browser tab is reflected in another within milliseconds.

Testing Real-Time Reactivity End-to-End

The most complex scenarios involve verifying that reactivity works correctly across multiple clients. Here is a pattern using two test clients:

// convex/realtime.test.ts
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "./schema";
import { api } from "./_generated/api";

test("reactive query updates when data changes", async () => {
  const t = convexTest(schema);

  // Set up initial state
  const projectId = await t.run(async (ctx) => {
    return await ctx.db.insert("projects", { name: "Reactive Test" });
  });

  // Query initial state
  const initialTasks = await t.query(api.tasks.listTasks, { projectId });
  expect(initialTasks).toHaveLength(0);

  // Mutate state
  await t.mutation(api.tasks.createTask, {
    projectId,
    title: "New reactive task",
    priority: "medium",
  });

  // Query again — should reflect new state
  const updatedTasks = await t.query(api.tasks.listTasks, { projectId });
  expect(updatedTasks).toHaveLength(1);
  expect(updatedTasks[0].title).toBe("New reactive task");
});

Best Practices

Isolate each test. The convexTest function creates a fresh in-memory database for every test. Never share state between tests—each scenario should set up its own fixtures.

Test the schema. Convex's schema validation runs at the database layer. Write tests that intentionally violate constraints to confirm errors are thrown correctly.

Cover scheduler interactions. If your mutations schedule follow-up work, call t.finishInProgressScheduledFunctions() and assert on the resulting state.

Separate unit and integration tests. Server-side function tests with convex-test are your unit layer. Reserve full browser tests for critical user flows where you need to validate the complete reactive pipeline from UI interaction to database update to UI refresh.

Use HelpMeTest for continuous monitoring. Deploy your Convex app to a staging environment and set up HelpMeTest to run automated checks on your most critical flows on every deploy. Its self-healing test capabilities handle the selector changes that come with rapid frontend iteration, keeping your test suite green without constant manual maintenance.

Convex's reactive architecture makes building real-time applications straightforward, but it demands a testing approach that goes beyond simple request-response assertions. Combine convex-test for server-side logic, mocked hooks for component behavior, and end-to-end tools for full reactive pipeline validation to build confidence at every layer.

Read more