Running tests using a local open-source backend
Note: This article was written before we released the convex-test
library along with the testing guidance to use it for unit tests. We generally recommend writing unit tests using convex-test
over using a local backend, and this section lists some of the pros and cons for testing against a local backend.
Convex recently released an open source version of the backend. We can use this to unit test Convex functions by running them with a local backend. These tests will work by running a backend locally, pushing your code, and using the ConvexClient
to execute queries and mutations from your favorite testing framework and asserting on the results.
Here’s an example of a simple test we might want to write if we were building a notes app:
1 test("can only read own notes", async () => {
2 const personASessionId = "Person A";
3 await t.mutation(api.notes.add, {
4 sessionId: personASessionId,
5 note: "Hello from Person A"
6 });
7
8 await t.mutation(api.notes.add, {
9 sessionId: personASessionId,
10 note: "Hello again from Person A"
11 });
12
13 const personBSessionId = "Person B";
14 await t.mutation(api.notes.add, {
15 sessionId: personBSessionId,
16 note: "Hello from Person B"
17 });
18
19 const personANotes = await t.query(api.notes.list, {
20 sessionId: personASessionId
21 });
22 expect(personANotes.length).toEqual(2);
23 });
24
Setting up a local backend
First, we need to make sure we are able to run a local backend by following the instructions here. Make sure just run-local-backend
runs successfully.
To make it easier to interact with the local backend from our project, we can add a Justfile
supporting the following commands: just run-local-backend
, just reset-local-backend
, and just convex [args]
to run Convex CLI commands against the local backend. These can be copied from the open source backend's Justfile
.
We want a command like npm run testFunctions
that does the following:
- Ensures a fresh local backend is running
- Sets the
IS_TEST
environment variable, allowing us to run testing only functions (just convex env set IS_TEST true
) - Pushes code to the local backend (
just convex deploy
) - Runs the tests (e.g.
npx vitest
) - Finally, tears down the local backend
One way to do this is to write a script, backendHarness.js
, that handles setting up the local backend, runs a command, and finally tears down the backend.
We can add commands to our package.json
that look something like this:
1// package.json
2"scripts": {
3 // Test assuming there's an existing local backend running
4 "testFunctionsExistingBackend": "just convex env set IS_TEST true && just convex deploy && vitest",
5 // Test handling local backend creation + cleanup
6 "testFunctions": "node backendHarness.js 'npm run testFunctionsExistingBackend'"
7}
8
Writing tests
In this set up, all our tests will be running against the same local backend, since running a backend for each test would be too slow. To keep tests isolated from each other, we want to configure our testing framework to run tests one at a time (e.g. maxWorkers: 1
in vitest.config.mts) and also call a function to clear out all our data after each test.
We’ll add a clearAll mutation that looks something like this:
1export const clearAll = testingMutation(async (ctx) => {
2 for (const table of Object.keys(schema.tables)) {
3 const docs = await ctx.db.query(table as any).collect();
4 await Promise.all(docs.map((doc) => ctx.db.delete(doc._id)));
5 }
6 const scheduled = await ctx.db.system.query("_scheduled_functions").collect();
7 await Promise.all(scheduled.map((s) => ctx.scheduler.cancel(s._id)));
8 const storedFiles = await ctx.db.system.query("_storage").collect();
9 await Promise.all(storedFiles.map((s) => ctx.storage.delete(s._id)));
10})
11
It clears data from all tables listed in the schema, cancels any scheduled jobs, and deletes any stored files.
The testingMutation
wrapper uses customMutation
from convex-helpers
to ensure all functions check that the IS_TEST
environment variable is set to prevent clearAll
from being called on Production or Development deployments.
1export const testingMutation = customMutation(mutation, {
2 args: {},
3 input: async (_ctx, _args) => {
4 if (process.env.IS_TEST === undefined) {
5 throw new Error(
6 "Calling a test only function in an unexpected environment"
7 );
8 }
9 return { ctx: {}, args: {} };
10 },
11});
12
Putting all of this together, we can write a test for our Convex functions which we can run via npm run testFunctions
:
1import { api } from "./_generated/api";
2import { ConvexTestingHelper } from "convex-helpers/testing";
3
4describe("testingExample", () => {
5 let t: ConvexTestingHelper;
6
7 beforeEach(() => {
8 t = new ConvexTestingHelper();
9 });
10
11 afterEach(async () => {
12 await t.mutation(api.testingFunctions.clearAll, {});
13 await t.close();
14 });
15
16 test("can only read own notes", async () => {
17 const personASessionId = "Person A";
18 await t.mutation(api.notes.add, {
19 sessionId: personASessionId,
20 note: "Hello from Person A"
21 });
22
23 await t.mutation(api.notes.add, {
24 sessionId: personASessionId,
25 note: "Hello again from Person A"
26 });
27
28 const personBSessionId = "Person B";
29 await t.mutation(api.notes.add, {
30 sessionId: personBSessionId,
31 note: "Hello from Person B"
32 });
33
34 const personANotes = await t.query(api.notes.list, {
35 sessionId: personASessionId
36 });
37 expect(personANotes.length).toEqual(2);
38 });
39});
40
A PR setting up tests for a Chess app built on Convex, following this guide, can be found here. It includes some examples of more complex tests.
It includes testing authenticated functions (this uses the same mechanism as the dashboard function tester to act as a particular user)
1 const sarahIdentity = t.newIdentity({ name: "Sarah" });
2 const asSarah = t.withIdentity(sarahIdentity);
3
4 const gameId = await asSarah.mutation(api.games.newGame, {
5 player1: "Me",
6 player2: null,
7 });
8 let game = await t.query(api.games.get, { id: gameId });
9 expect(game.player1Name).toEqual("Sarah");
10
It also uses test only mutations to set up data (setting up a chess game 2 moves from the end):
1// Two moves before the end of the game
2const gameAlmostFinishedPgn = "1. Nf3 Nf6 2. d4 Nc6 3. e4 Nxe4" /* ... */
3
4const gameId = await t.mutation(api.testing.setupGame, {
5 player1: sarahId,
6 player2: leeId,
7 pgn: gameAlmostFinishedPgn,
8 finished: false,
9});
10
Limitations
Scheduled functions (ctx.scheduler
and crons) will run in this local backend as time progresses, and while it’s possible to check the state of a scheduled job using ctx.db.system
, there’s no built in way to run a scheduled function for testing or to mock out time and manually advance it in tests.
There is also no built in way to mock out or control randomness in the tested functions.
See the documentation for a comparison of this testing strategy with unit tests using the convex-test
library.
Summary
Convex functions can be unit tested using a locally running Convex backend.
Check out the open source backend or see an example of these tests in action!
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.