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 generate
your-test-file.ts
import { 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.

convex/discordActions.test.ts
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 name
restore()Remove mock and restore original behavior
reset()Clear recorded calls (mock stays active)
createMockDiscordActions(options?)

Creates a standalone mock with the same interface as createDiscordActions().

Returns

sendMessage(...)All 29 Discord action methods
getCalls(action?)Recorded calls, optionally filtered
reset()Clear recorded calls
MockOptions

Options accepted by both mock functions.

defaultResponse?DiscordResult — returned for all actions (default: { ok: true, data: {} })
responses?Record<string, DiscordResult> — per-action overrides
DiscordActionCall

Shape 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.

convex/discordActions.test.ts
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.