Skip to main content

Testing

Automating the testing of your Convex functions is easy.

Get Started

  1. Install test dependencies

    Install Vitest and the convex-test library.

    npm install --save-dev vitest convex-test
  2. Setup NPM scripts

    Add these scripts to your package.json

    package.json
    "scripts": {
    "test": "vitest",
    "test:once": "vitest run",
    "test:debug": "vitest --inspect-brk --no-file-parallelism"
    }
  3. Add a test file

    In your convex folder add a file ending in .test.ts

    The example test calls the api.messages.send mutation twice and then asserts that the api.messages.list query returns the expected results.

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

    test("sending messages", async () => {
    const t = convexTest(schema);
    await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" });
    await t.mutation(api.messages.send, { body: "Hey!", author: "Tom" });
    const messages = await t.query(api.messages.list);
    expect(messages).toMatchObject([
    { body: "Hi!", author: "Sarah" },
    { body: "Hey!", author: "Tom" }
    ]);
    });
  4. Run tests

    Start the tests with npm run test. When you change the test file the tests will rerun automatically.

    You might need to hit Enter or the R key to rerun the tests when you change your functions.

    npm run test

If you're not familiar with Vitest or Jest read the Vitest Getting Started docs first.

convex-test library

The convex-test library provides a community-maintained mock implementation of the Convex backend in TypeScript.

Example: The library includes a test suite which you can browse to see examples of using it.

convexTest

The library exports a convexTest function which should be called at the start of each of your tests. The function returns an object which is by convention stored in the t variable and which provides methods for exercising your Convex functions.

If your project uses a schema you should pass it to the convexTest function:

convex/myFunctions.test.ts
import { convexTest } from "convex-test";
import { test } from "vitest";
import schema from "./schema";

test("some behavior", async () => {
const t = convexTest(schema);
// use `t`...
});

Passing in the schema is required for the tests to correctly implement schema validation and for correct typing of t.run.

If you don't have a schema, call convexTest() with no argument.

Calling functions with t.query, t.mutation and t.action

Your test can call public and internal Convex functions in your project:

convex/myFunctions.test.ts
import { convexTest } from "convex-test";
import { test } from "vitest";
import { api, internal } from "./_generated/api";

test("functions", async () => {
const t = convexTest();
const x = await t.query(api.myFunctions.myQuery, { a: 1, b: 2 });
const y = await t.query(internal.myFunctions.internalQuery, { a: 1, b: 2 });
const z = await t.mutation(api.myFunctions.mutateSomething, { a: 1, b: 2 });
const w = await t.mutation(internal.myFunctions.mutateSomething, { a: 1 });
const u = await t.action(api.myFunctions.doSomething, { a: 1, b: 2 });
const v = await t.action(internal.myFunctions.internalAction, { a: 1, b: 2 });
});

Setting up and inspecting data and storage with t.run

Sometimes you might want to directly write to the mock database or file storage from your test, without needing a declared function in your project. You can use the t.run method which takes a handler that is given a ctx that allows reading from and writing to the mock backend:

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

test("functions", async () => {
const t = convexTest();
const firstTask = await t.run(async (ctx) => {
await ctx.db.insert("tasks", { text: "Eat breakfast" });
return await ctx.db.query("tasks").first();
});
expect(firstTask).toMatchObject({ text: "Eat breakfast" });
});

Testing scheduled functions

One advantage of using a mock implementation running purely in JavaScript is that you can control time in the Vitest test environment. To test implementations relying on scheduled functions use Vitest's fake timers in combination with t.finishInProgressScheduledFunctions:

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

test("mutation scheduling action", async () => {
// Enable fake timers
vi.useFakeTimers();

const t = convexTest(schema);

// Call a function that schedules a mutation or action
const scheduledFunctionId = await t.mutation(
api.scheduler.mutationSchedulingAction,
{ delayMs: 10000 },
);

// Advance the mocked time
vi.advanceTimersByTime(5000);

// Advance the mocked time past the scheduled time of the function
vi.advanceTimersByTime(6000);

// Or run all currently pending timers
vi.runAllTimers();

// At this point the scheduled function will be `inProgress`,
// now wait for it to finish:
await t.finishInProgressScheduledFunctions();

// Assert that the scheduled function succeeded or failed
const scheduledFunctionStatus = t.run(async (ctx) => {
return ctx.db.get(scheduledFunctionId);
});
expect(scheduledFunctionStatus).toMatchObject({ state: { kind: "success" } });

// Reset to normal `setTimeout` etc. implementation
vi.useRealTimers();
});

Check out more examples in this file.

Testing authentication with t.withIdentity

To test functions which depend on the current authenticated user identity you can create a version of the t accessor with given user identity attributes. If you don't provide them, issuer, subject and tokenIdentifier will be generated automatically:

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

test("authenticated functions", async () => {
const t = convexTest(schema);

const asSarah = t.withIdentity({ name: "Sarah" });
await asSarah.mutation(api.tasks.create, { text: "Add tests" });

const sarahsTasks = await asSarah.query(api.tasks.list);
expect(sarahsTasks).toMatchObject([{ text: "Add tests" }]);

const asLee = t.withIdentity({ name: "Lee" });
const leesTasks = await asLee.query(api.tasks.list);
expect(leesTasks).toEqual([]);
});

Mocking fetch calls

You can use Vitest's vi.stubGlobal method:

ai.test.ts
import { expect, test, vi } from "vitest";
import { convexTest } from "../index";
import { api } from "./_generated/api";
import schema from "./schema";

test("ai", async () => {
const t = convexTest(schema);

vi.stubGlobal(
"fetch",
vi.fn(async () => ({ text: async () => "I am the overlord" }) as Response),
);

const reply = await t.action(api.messages.sendAIMessage, { prompt: "hello" });
expect(reply).toEqual("I am the overlord");

vi.unstubAllGlobals();
});

Asserting results

See Vitest's Expect reference.

toMatchObject() is particularly helpful when asserting the shape of results without needing to list every object field.

Asserting errors

To assert that a function throws, use .rejects.toThrowError():

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

test("messages validation", async () => {
const t = convexTest(schema);
expect(async () => {
await t.mutation(api.messages.send, { body: "", author: "James" });
}).rejects.toThrowError("Empty message body is not allowed");
});

Debugging tests

You can attach a debugger to the running tests. Read the Vitest Debugging docs and then use npm run test:debug.

Limitations

Since convex-test is only a mock implementation, it doesn't have many of the behaviors of the real Convex backend. Still, it should be helpful for testing the logic in your functions, and catching regressions caused by changes to your code.

Some of the ways the mock differs:

  • Error messages content. You should not write product logic that relies on the content of error messages thrown by the real backend, as they are always subject to change.
  • Limits. The mock doesn't enforce size and time limits.
  • ID format. Your code should not depend on the document or storage ID format.
  • Runtime built-ins. Most of your functions are written for the Convex default runtime, while Vitest runs your tests in your local Node.js runtime. You should always test new code manually to make sure it doesn't use built-ins not available in the Convex runtime.
  • Some features have only simplified semantics, namely:
    • Text search returns all documents that include a word for which at least one word in the searched string is a prefix. It does not implement fuzzy searching and doesn't sort the results by relevance.
    • Vector search returns results sorted by cosine similarity, but doesn't use an efficient vector index in its implementation.
    • There is no support for cron jobs, you should trigger your functions manually from the test.

Testing using the local backend

Alternatively to convex-test you can test your functions using the open-source version of Convex backend. Follow this guide for the instructions.

This method allows for an additional test coverage:

  • Your tests will run against the same code as your Convex production (as long you keep the local backend up-to-date).
  • Limits on argument, data, query sizes are enforced.
  • You can bootstrap a large test dataset from a snapshot import.
  • You can test your client code in combination with your backend logic.

Note that testing against the local backend also has some drawbacks:

  • It requires setting up the local backend, which is more involved.
  • No control over time and any scheduled functions will run as scheduled.
  • Crons will also run unless disabled via IS_TEST.
  • No way to mock fetch calls.
  • No way to mock dependencies or parts of the codebase.
  • No way to control randomness (tests may not be deterministic).
  • No way to set environment variable values from within tests.

Testing in GitHub Actions

It's easy if you're using GitHub to set up CI (continuous integration) workflow for running your test suite:

.github/workflows/test.yml
name: Run Tests

on: [pull_request, push]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run test

After you commit and push this file to your repository, GitHub will run npm run test every time you create a pull request or push a new commit.