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 vitestBasic 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.tsThe 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
- Auth gates work — unauthenticated calls to protected functions throw
- Cross-channel isolation — queries for channel A don't return data from channel B
- Mutation atomicity — if a mutation inserts multiple documents and one fails, none are persisted
- Schema validation — invalid argument types are rejected before the handler runs
- 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.