tests/

Put test files in tests/. Same runner as agent tests — bun:test with Axon() from @axon/test. The difference: you are not testing the module in isolation. Modules have no standalone runtime. You boot a parent agent with the module loaded and test the contribution surface — what tools appeared, what prompts render, how hooks behave.

tests/
├── tools.test.ts
├── prompts.test.ts
└── hooks.test.ts

The harness

Axon() auto-discovers the nearest axon.config.ts by walking up from the current working directory. Run tests from inside an agent that has your module installed. The harness boots that agent's full runtime — your module's setup() runs, its tools are registered, its prompts are available.

import { describe, it, expect, afterAll } from "bun:test"
import { Axon } from "@axon/test"
import { Mock } from "@axon/engines"

describe("discord module", () => {
    const { axon, stop } = await Axon({ config: { engine: Mock() } })
    afterAll(() => stop())

    it("registers the discord tool namespace", async () => {
        expect(axon.tools.discord).toBeDefined()
    })
})

Testing the tool surface

Verify that the tools your module declares are present and callable. Call tools directly via axon.tools.* — no agent loop involved, just the function.

it("exposes expected tools", async () => {
    const { axon, stop } = await Axon({ config: { engine: Mock() } })

    expect(typeof axon.tools.discord.play).toBe("function")
    expect(typeof axon.tools.discord.skip).toBe("function")
    expect(typeof axon.tools.discord.queue).toBe("function")

    await stop()
})

it("queue returns empty state when nothing is playing", async () => {
    const { axon, stop } = await Axon({ config: { engine: Mock() } })

    const state = await axon.tools.discord.queue(guildId)
    expect(state).toEqual({ current: null, upcoming: [] })

    await stop()
})

Tools that depend on external connections (Discord client, databases, IMAP) will throw in test environments where those services aren't present. Test the logic you control — state management, return shapes, guard conditions — not the external service.

Testing prompts

Render the prompts your module contributes and assert on their content. This catches template regressions — broken variable interpolation, missing sections, stale copy.

it("renders the discord prompt with required props", async () => {
    const { axon, stop } = await Axon({ config: { engine: Mock() } })

    const prompt = await axon.prompt("discord", {
        content: "hello",
        username: "cody",
        channelId: "123",
        thread: [],
        nowPlaying: { current: null, upcoming: [] },
    })

    expect(prompt.content).toContain("cody")
    expect(prompt.content).toContain("hello")

    await stop()
})

Test the dynamic cases — prompts that branch on data. If your prompt surfaces nowPlaying state conditionally, assert it appears when the data is present and doesn't when it isn't.

it("surfaces now-playing state when music is active", async () => {
    const { axon, stop } = await Axon({ config: { engine: Mock() } })

    const prompt = await axon.prompt("discord", {
        content: "whats playing?",
        username: "cody",
        channelId: "123",
        thread: [],
        nowPlaying: { current: "Bohemian Rhapsody", upcoming: ["Heroes"] },
    })

    expect(prompt.content).toContain("Bohemian Rhapsody")
    expect(prompt.content).toContain("Heroes")

    await stop()
})

Testing hooks

Your module emits hooks — fire them directly via axon.hooks.callHook() and assert on the outcome. This is how you test the full path: hook fires, agent processes it, callback receives a reply.

async function sendMessage(axon: AxonHandle, content: string): Promise<string | null> {
    let replied: string | null = null
    await axon.hooks.callHook("discord:message.received", {
        content,
        username: "cody",
        channelId: "123",
        guildId: "456",
        userId: "789",
        reply: async (text: string) => { replied = text },
    })
    return replied
}

it("fires a reply when a message hook is triggered", async () => {
    const { axon, stop } = await Axon({
        config: { engine: Mock(() => air.text("hey cody")) },
    })

    const reply = await sendMessage(axon, "hello barry")
    expect(reply).toBe("hey cody")

    await stop()
})

Testing tool calls through the loop

When the agent responds to a hook by calling one of your module's tools, use isFollowUpTick to handle the two-tick loop. First tick: return the tool call. Second tick: return the reply.

import { Mock, air, isFollowUpTick } from "@axon/engines"

it("agent calls play tool when asked", async () => {
    const { axon, stop } = await Axon({
        config: {
            engine: Mock((req) => {
                if (isFollowUpTick(req)) return air.text("Now playing.")
                return air.typescript(`discord.play("track", userId, guildId)`)
            }),
        },
    })

    const reply = await sendMessage(axon, "play something")
    expect(reply).toBeTruthy()

    await stop()
})

The agent loop runs to completion. The tool executes (or throws if the client isn't present), the agent receives the result, and the second tick produces the reply.

Running tests

Run from inside the agent directory that has your module installed:

bun test
bun test tests/hooks.test.ts
bun test --watch