How to Test Convex Applications: Functions, Mutations, and Real-Time Queries

How to Test Convex Applications: Functions, Mutations, and Real-Time Queries

Convex is a backend platform that bundles your database, backend functions (queries, mutations, actions), and real-time subscriptions together. The testing story is distinct from traditional backends: you're not testing a REST API — you're testing TypeScript functions that run inside the Convex runtime, with reactive query subscriptions that push updates to clients automatically.

This guide covers how to unit test Convex queries and mutations, test actions (async functions that call external APIs), and E2E test the reactive UI behavior.

Convex Architecture for Testing

A Convex app has four types of backend functions:

// Queries — read-only, reactive, automatically re-run on data change
export const getMessages = query({
  args: { channelId: v.id("channels") },
  handler: async (ctx, args) => {
    return ctx.db
      .query("messages")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .order("desc")
      .take(50);
  },
});

// Mutations — transactional writes
export const sendMessage = mutation({
  args: { channelId: v.id("channels"), text: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    return ctx.db.insert("messages", {
      channelId: args.channelId,
      userId,
      text: args.text,
      createdAt: Date.now(),
    });
  },
});

// Actions — async, can call external APIs, run in Node.js
export const sendEmailNotification = action({
  args: { userId: v.id("users"), message: v.string() },
  handler: async (ctx, args) => {
    const user = await ctx.runQuery(internal.users.getById, { id: args.userId });
    await fetch("https://api.email.example.com/send", {
      method: "POST",
      body: JSON.stringify({ to: user.email, body: args.message }),
    });
  },
});

Testing with the Convex Testing Library

Convex provides convex-test — an official testing library that runs queries and mutations against an in-memory Convex database:

npm install --save-dev convex-test vitest

Basic setup:

// convex/__tests__/messages.test.ts
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "../_generated/api";
import schema from "../schema";

describe("Messages", () => {
  it("inserts and retrieves a message", async () => {
    const t = convexTest(schema);

    // Create a channel first
    const channelId = await t.mutation(api.channels.create, {
      name: "general",
    });

    // Send a message
    const messageId = await t.mutation(api.messages.sendMessage, {
      channelId,
      text: "Hello, world!",
    });

    // Query it back
    const messages = await t.query(api.messages.getMessages, { channelId });

    expect(messages).toHaveLength(1);
    expect(messages[0].text).toBe("Hello, world!");
    expect(messages[0]._id).toBe(messageId);
  });
});

Unit Testing Queries

Test query filtering, ordering, and edge cases:

// convex/__tests__/queries.test.ts
import { convexTest } from "convex-test";
import { describe, it, expect, beforeEach } from "vitest";
import { api } from "../_generated/api";
import schema from "../schema";

describe("getMessages query", () => {
  async function seedMessages(t: ReturnType<typeof convexTest>, channelId: string) {
    for (let i = 1; i <= 5; i++) {
      await t.mutation(api.messages.sendMessage, {
        channelId,
        text: `Message ${i}`,
      });
    }
  }

  it("returns messages in descending order", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "test" });
    await seedMessages(t, channelId);

    const messages = await t.query(api.messages.getMessages, { channelId });

    // Most recent first
    const texts = messages.map((m) => m.text);
    expect(texts[0]).toBe("Message 5");
    expect(texts[4]).toBe("Message 1");
  });

  it("returns at most 50 messages", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "big" });

    // Insert 60 messages
    for (let i = 1; i <= 60; i++) {
      await t.mutation(api.messages.sendMessage, {
        channelId,
        text: `Msg ${i}`,
      });
    }

    const messages = await t.query(api.messages.getMessages, { channelId });

    expect(messages).toHaveLength(50);
  });

  it("returns empty array for channel with no messages", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "empty" });

    const messages = await t.query(api.messages.getMessages, { channelId });

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

  it("only returns messages from the specified channel", async () => {
    const t = convexTest(schema);
    const channelA = await t.mutation(api.channels.create, { name: "A" });
    const channelB = await t.mutation(api.channels.create, { name: "B" });

    await t.mutation(api.messages.sendMessage, { channelId: channelA, text: "In A" });
    await t.mutation(api.messages.sendMessage, { channelId: channelB, text: "In B" });

    const messagesA = await t.query(api.messages.getMessages, { channelId: channelA });
    const messagesB = await t.query(api.messages.getMessages, { channelId: channelB });

    expect(messagesA).toHaveLength(1);
    expect(messagesA[0].text).toBe("In A");
    expect(messagesB).toHaveLength(1);
    expect(messagesB[0].text).toBe("In B");
  });
});

Unit Testing Mutations

Test that mutations write the correct data and enforce invariants:

// convex/__tests__/mutations.test.ts
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "../_generated/api";
import schema from "../schema";

describe("sendMessage mutation", () => {
  it("returns the new message ID", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "test" });

    const messageId = await t.mutation(api.messages.sendMessage, {
      channelId,
      text: "Hello",
    });

    expect(typeof messageId).toBe("string");
    expect(messageId).toMatch(/^[a-z0-9]+$/i);
  });

  it("stores the correct fields on the message", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "test" });

    const messageId = await t.mutation(api.messages.sendMessage, {
      channelId,
      text: "Test content",
    });

    // Use runQuery to inspect the stored document
    const message = await t.run(async (ctx) => ctx.db.get(messageId));

    expect(message).not.toBeNull();
    expect(message!.text).toBe("Test content");
    expect(message!.channelId).toBe(channelId);
    expect(message!.createdAt).toBeGreaterThan(0);
  });

  it("rejects empty message text", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "test" });

    await expect(
      t.mutation(api.messages.sendMessage, { channelId, text: "" })
    ).rejects.toThrow();
  });

  it("rejects message text exceeding max length", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "test" });
    const tooLong = "x".repeat(10_001);

    await expect(
      t.mutation(api.messages.sendMessage, { channelId, text: tooLong })
    ).rejects.toThrow();
  });
});

describe("deleteMessage mutation", () => {
  it("removes the message from the database", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "test" });
    const messageId = await t.mutation(api.messages.sendMessage, {
      channelId,
      text: "To be deleted",
    });

    await t.mutation(api.messages.deleteMessage, { messageId });

    const message = await t.run(async (ctx) => ctx.db.get(messageId));
    expect(message).toBeNull();
  });

  it("throws when deleting a message that doesn't exist", async () => {
    const t = convexTest(schema);
    const fakeId = "fake_message_id" as any;

    await expect(
      t.mutation(api.messages.deleteMessage, { messageId: fakeId })
    ).rejects.toThrow();
  });
});

Testing Actions with Mocked External Calls

Actions can call external APIs. Use vi.mock to stub the HTTP calls:

// convex/__tests__/actions.test.ts
import { convexTest } from "convex-test";
import { describe, it, expect, vi } from "vitest";
import { api } from "../_generated/api";
import schema from "../schema";

// Mock the external email API
vi.mock("../lib/emailClient", () => ({
  sendEmail: vi.fn().mockResolvedValue({ success: true }),
}));

describe("sendEmailNotification action", () => {
  it("sends an email with the correct message", async () => {
    const { sendEmail } = await import("../lib/emailClient");

    const t = convexTest(schema);

    // Seed a user
    const userId = await t.mutation(api.users.create, {
      email: "user@example.com",
      name: "Test User",
    });

    await t.action(api.notifications.sendEmailNotification, {
      userId,
      message: "Your order has shipped!",
    });

    expect(sendEmail).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "user@example.com",
        body: expect.stringContaining("Your order has shipped!"),
      })
    );
  });

  it("throws when the user does not exist", async () => {
    const t = convexTest(schema);
    const nonExistentId = "non_existent_user" as any;

    await expect(
      t.action(api.notifications.sendEmailNotification, {
        userId: nonExistentId,
        message: "Hello",
      })
    ).rejects.toThrow();
  });
});

Testing Auth-Gated Functions

Convex functions often check the authenticated user. Test both authenticated and unauthenticated cases:

describe("sendMessage with auth", () => {
  it("requires authentication", async () => {
    const t = convexTest(schema);
    const channelId = await t.mutation(api.channels.create, { name: "private" });

    // Call without auth — should throw
    await expect(
      t.mutation(api.messages.sendMessage, { channelId, text: "Hello" })
    ).rejects.toThrow(/not authenticated/i);
  });

  it("uses the authenticated user's ID on the message", async () => {
    const t = convexTest(schema);

    // Create test identity
    const userId = await t.run(async (ctx) =>
      ctx.db.insert("users", { email: "alice@example.com", name: "Alice" })
    );

    // Run as authenticated user
    const channelId = await t.mutation(api.channels.create, { name: "auth-test" });
    const messageId = await t.withIdentity({ subject: userId }).mutation(
      api.messages.sendMessage,
      { channelId, text: "Authenticated message" }
    );

    const message = await t.run(async (ctx) => ctx.db.get(messageId));
    expect(message!.userId).toBe(userId);
  });
});

E2E Testing Reactive Queries with Playwright

The real power of Convex is reactive UI — when data changes, the UI updates automatically. Test this with Playwright:

// tests/realtime.e2e.ts
import { test, expect } from "@playwright/test";

test.describe("Real-time messaging", () => {
  test("message appears in second browser tab without refresh", async ({
    browser,
  }) => {
    // Open two browser contexts (simulating two users)
    const context1 = await browser.newContext();
    const context2 = await browser.newContext();

    const page1 = await context1.newPage();
    const page2 = await context2.newPage();

    // Both navigate to the same channel
    await page1.goto("/channels/general");
    await page2.goto("/channels/general");

    // Page 1 sends a message
    const uniqueText = `Test message ${Date.now()}`;
    await page1.getByPlaceholder("Type a message").fill(uniqueText);
    await page1.keyboard.press("Enter");

    // Page 2 should see it without any refresh
    await expect(
      page2.getByText(uniqueText)
    ).toBeVisible({ timeout: 5000 });

    await context1.close();
    await context2.close();
  });

  test("message count updates in real time", async ({ browser }) => {
    const context1 = await browser.newContext();
    const context2 = await browser.newContext();

    const page1 = await context1.newPage();
    const page2 = await context2.newPage();

    await page1.goto("/channels/general");
    await page2.goto("/channels/general");

    const initialCount = await page2.getByTestId("message-count").textContent();
    const expectedCount = String(parseInt(initialCount ?? "0") + 1);

    // Send from page1
    await page1.getByPlaceholder("Type a message").fill("Count test message");
    await page1.keyboard.press("Enter");

    // Count on page2 should update
    await expect(page2.getByTestId("message-count")).toHaveText(expectedCount, {
      timeout: 5000,
    });

    await context1.close();
    await context2.close();
  });
});

Schema Validation Testing

Convex validates function arguments against your schema at runtime. Test that your schema correctly rejects bad inputs:

describe("Schema validation", () => {
  it("rejects invalid channelId type", async () => {
    const t = convexTest(schema);
    await expect(
      t.query(api.messages.getMessages, { channelId: 12345 as any })
    ).rejects.toThrow();
  });

  it("rejects missing required fields", async () => {
    const t = convexTest(schema);
    await expect(
      t.mutation(api.messages.sendMessage, { channelId: "valid_id" } as any)
    ).rejects.toThrow();
  });
});

Test Organization

For a Convex app, organize tests by function type:

convex/
  __tests__/
    queries/
      messages.test.ts
      channels.test.ts
    mutations/
      messages.test.ts
      users.test.ts
    actions/
      notifications.test.ts
    schema.test.ts

tests/                    # Playwright E2E
  realtime.e2e.ts
  auth.e2e.ts

The convex-test library's in-memory database is fast — unit tests complete in milliseconds. Run the full unit test suite on every commit and reserve E2E tests for pre-deploy.

Key Invariants to Test in Every Convex App

  1. Auth gates work — unauthenticated calls to protected functions throw
  2. Cross-channel isolation — queries for channel A don't return data from channel B
  3. Mutation atomicity — if a mutation inserts multiple documents and one fails, none are persisted
  4. Schema validation — invalid argument types are rejected before the handler runs
  5. Reactive correctness — E2E test that a mutation in one tab causes a UI update in another tab within 3–5 seconds

These five invariants catch the most common Convex bugs. Build them into your test suite before you ship.

Read more

Start now free