Testing
Mock Discord actions in your Convex tests
Calabasas generates mock utilities so you can test Convex actions that call Discord without mocking fetch globally. Record calls, assert on arguments, and simulate error responses.
Two ways to mock
mockDiscordActions()Module-level mock that intercepts all createDiscordActions() calls globally.
Best for integration tests where your action creates its own Discord client internally.
createMockDiscordActions()Standalone drop-in replacement with the same interface as createDiscordActions().
Best when you can inject the Discord client directly.
Setup
The testing utilities are auto-generated. Just run calabasas generate and import from the generated file:
npx calabasas generateimport { mockDiscordActions, createMockDiscordActions } from "./calabasas/_generated/testing";
import type { DiscordActionCall } from "./calabasas/_generated/testing";The testing file is regenerated every time you run calabasas generate. If new Discord actions are added, your mocks will automatically include them.
Module-level mock
Intercepts all createDiscordActions() calls globally. Your code doesn't need to change at all — the mock hooks into the internal call proxy.
import { mockDiscordActions } from "./calabasas/_generated/testing";
const mock = mockDiscordActions();
// Your action internally calls createDiscordActions("app-id")
// and then discord.sendMessage(...) — all intercepted automatically.
// Assert on what was called
const calls = mock.getCalls("sendMessage");
expect(calls).toHaveLength(1);
expect(calls[0].args.channelId).toBe("123456");
expect(calls[0].args.content).toBe("Hello!");
// Get ALL calls across all actions
const allCalls = mock.getCalls();
expect(allCalls).toHaveLength(3); // sendMessage + addRole + ...
// Clean up
mock.restore();How it works
The generated discord.actions.ts has a swappable _callProxyOverride hook. When mockDiscordActions() is called, it sets this override to a recording function. Every createDiscordActions() instance routes through the same override, capturing all calls regardless of where the client was created.
Always call mock.restore() after each test to avoid polluting other tests. Use try/finally or afterEach hooks.
Standalone mock
Use as a direct replacement for createDiscordActions(). Has all the same methods — sendMessage, editMessage, kick, ban, addRole, setPresence, and every other action.
import { createMockDiscordActions } from "./calabasas/_generated/testing";
const discord = createMockDiscordActions();
// Use like the real thing
await discord.sendMessage("channel-1", { content: "Hello!" });
await discord.addRole("guild-1", "user-1", "role-1");
// Assert
const messageCalls = discord.getCalls("sendMessage");
expect(messageCalls).toHaveLength(1);
expect(messageCalls[0].args.content).toBe("Hello!");
const allCalls = discord.getCalls();
expect(allCalls).toHaveLength(2);
// Clear for next assertions
discord.reset();No restore() needed — the standalone mock doesn't modify any global state. Just discard it when you're done.
Custom responses
Both mocks return { ok: true, data: {} } by default. Override globally or per-action.
Default response
const mock = mockDiscordActions({
defaultResponse: { ok: true, data: { id: "msg-123" } },
});Per-action responses
const mock = mockDiscordActions({
responses: {
sendMessage: { ok: true, data: { id: "msg-123", content: "Hello!" } },
deleteMessage: { ok: false, status: 404, error: "Unknown Message" },
ban: { ok: false, status: 403, error: "Missing Permissions" },
},
});
// sendMessage → { ok: true, data: { id: "msg-123", ... } }
// deleteMessage → { ok: false, status: 404, error: "Unknown Message" }
// kick → { ok: true, data: {} } (falls back to default)API reference
mockDiscordActions(options?)Installs a module-level mock that intercepts all createDiscordActions() calls.
Returns
getCalls(action?)Recorded calls, optionally filtered by action namerestore()Remove mock and restore original behaviorreset()Clear recorded calls (mock stays active)createMockDiscordActions(options?)Creates a standalone mock with the same interface as createDiscordActions().
Returns
sendMessage(...)All 29 Discord action methodsgetCalls(action?)Recorded calls, optionally filteredreset()Clear recorded callsMockOptionsOptions accepted by both mock functions.
defaultResponse?DiscordResult — returned for all actions (default: { ok: true, data: {} })responses?Record<string, DiscordResult> — per-action overridesDiscordActionCallShape of each recorded call.
type DiscordActionCall = {
action: string; // e.g., "sendMessage"
args: Record<string, unknown>; // merged arguments
timestamp: number; // Date.now() at call time
};Example with convex-test
A complete example testing a welcome message action and an error handling path.
import { convexTest } from "convex-test";
import { mockDiscordActions } from "./calabasas/_generated/testing";
import schema from "./schema";
import { api } from "./_generated/api";
describe("welcome handler", () => {
test("sends welcome message on member join", async () => {
const t = convexTest(schema);
const mock = mockDiscordActions();
try {
await t.action(api.discordActions.welcomeNewMember, {
channelId: "welcome-channel",
userId: "new-user-123",
});
const calls = mock.getCalls("sendMessage");
expect(calls).toHaveLength(1);
expect(calls[0].args.channelId).toBe("welcome-channel");
expect(calls[0].args.content).toContain("new-user-123");
} finally {
mock.restore();
}
});
test("handles ban failure gracefully", async () => {
const t = convexTest(schema);
const mock = mockDiscordActions({
responses: {
ban: { ok: false, status: 403, error: "Missing Permissions" },
},
});
try {
const result = await t.action(api.discordActions.banUser, {
guildId: "guild-1",
userId: "user-1",
});
expect(result).toBe("insufficient_permissions");
} finally {
mock.restore();
}
});
});Tips
Always restore module mocks
Use try/finally or afterEach(() => mock.restore()) to prevent test pollution. The standalone mock doesn't need this.
Use reset() between assertions
Call mock.reset() to clear recorded calls without removing the mock. Useful when testing multiple interactions in one test.
Check call ordering with timestamps
Each DiscordActionCall has a timestamp field. Use it to verify actions were called in the expected order.
No real network calls
Both mocks resolve promises synchronously with Promise.resolve(). No HTTP requests are made, making tests fast and deterministic.