chat
Version:
Unified chat abstraction for Slack, Teams, Google Chat, and Discord
499 lines (401 loc) • 14.8 kB
text/mdx
---
title: Testing adapters
description: Write unit tests, integration tests, and replay tests for community Chat SDK adapters.
type: guide
prerequisites:
- /docs/contributing/building
related:
- /docs/adapters
---
## Testing philosophy
Chat SDK adapters are the trust boundary between your application and a platform. High test coverage is expected: unit tests for every public method, and integration tests that wire up the full `Chat` → `Adapter` → handler pipeline.
All adapters in this repo use [vitest](https://vitest.dev) with `@vitest/coverage-v8`. Community adapters should follow the same convention.
<Callout type="info">
This page covers the hand-rolled patterns used inside this repo's `packages/`. If you're testing a bot or a custom adapter as a **consumer** of Chat SDK, use [`@chat-adapter/tests`](/docs/testing) — it ships factories and Vitest matchers that cover most of these patterns in a few lines.
</Callout>
## Unit tests
### Factory function
Verify that the factory validates config, reads environment variables, and sets defaults.
```typescript title="src/factory.test.ts" lineNumbers
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createMatrixAdapter } from "./factory";
describe("createMatrixAdapter", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it("creates adapter from explicit config", () => {
const adapter = createMatrixAdapter({
homeserverUrl: "https://matrix.example.com",
accessToken: "syt_test_token",
});
expect(adapter.name).toBe("matrix");
});
it("reads environment variables as fallback", () => {
process.env.MATRIX_HOMESERVER_URL = "https://matrix.example.com";
process.env.MATRIX_ACCESS_TOKEN = "syt_test_token";
const adapter = createMatrixAdapter();
expect(adapter.name).toBe("matrix");
});
it("throws when homeserver URL is missing", () => {
expect(() => createMatrixAdapter({ accessToken: "tok" } as never))
.toThrow("homeserver URL");
});
it("throws when access token is missing", () => {
expect(() =>
createMatrixAdapter({
homeserverUrl: "https://matrix.example.com",
} as never)
).toThrow("access token");
});
});
```
### Thread ID encode/decode
Verify that `encodeThreadId` and `decodeThreadId` roundtrip consistently and reject invalid formats.
```typescript title="src/thread-id.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";
const adapter = new MatrixAdapter({
homeserverUrl: "https://matrix.example.com",
accessToken: "syt_test_token",
});
describe("thread ID encoding", () => {
it("roundtrips room-only thread ID", () => {
const data = { roomId: "!abc123:matrix.org" };
const encoded = adapter.encodeThreadId(data);
const decoded = adapter.decodeThreadId(encoded);
expect(decoded.roomId).toBe(data.roomId);
expect(encoded).toMatch(/^matrix:/);
});
it("roundtrips thread ID with event", () => {
const data = {
roomId: "!abc123:matrix.org",
eventId: "$event456",
};
const encoded = adapter.encodeThreadId(data);
const decoded = adapter.decodeThreadId(encoded);
expect(decoded.roomId).toBe(data.roomId);
expect(decoded.eventId).toBe(data.eventId);
});
it("throws on invalid format", () => {
expect(() => adapter.decodeThreadId("invalid")).toThrow();
expect(() => adapter.decodeThreadId("slack:C123:ts")).toThrow();
});
});
```
### Webhook signature verification
Test the three key scenarios: missing headers, invalid signature, and valid signature.
```typescript title="src/webhook.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";
const adapter = new MatrixAdapter({
homeserverUrl: "https://matrix.example.com",
accessToken: "syt_test_token",
});
describe("handleWebhook", () => {
it("returns 401 when signature header is missing", async () => {
const request = new Request("https://example.com/webhook", {
method: "POST",
body: "{}",
});
const response = await adapter.handleWebhook(request);
expect(response.status).toBe(401);
});
it("returns 401 when signature is invalid", async () => {
const request = new Request("https://example.com/webhook", {
method: "POST",
headers: { "x-matrix-signature": "invalid" },
body: "{}",
});
const response = await adapter.handleWebhook(request);
expect(response.status).toBe(401);
});
it("returns 200 for valid signed request", async () => {
const body = JSON.stringify({ type: "m.room.message" });
const request = createSignedRequest(body); // Your signing helper
const response = await adapter.handleWebhook(request);
expect(response.status).toBe(200);
});
});
```
### Message parsing
Cover the main message types: plain text, bot messages, DMs, edited messages, attachments, and formatted text.
```typescript title="src/parse-message.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixAdapter } from "./adapter";
const adapter = new MatrixAdapter({
homeserverUrl: "https://matrix.example.com",
accessToken: "syt_test_token",
});
describe("parseMessage", () => {
it("parses a plain text message", () => {
const raw = {
event_id: "$evt1",
room_id: "!room1:matrix.org",
body: "Hello world",
sender: "@alice:matrix.org",
sender_display_name: "Alice",
origin_server_ts: 1700000000000,
};
const message = adapter.parseMessage(raw);
expect(message.text).toBe("Hello world");
expect(message.author.userId).toBe("@alice:matrix.org");
expect(message.author.isBot).toBe(false);
});
it("detects bot messages", () => {
const raw = {
event_id: "$evt2",
room_id: "!room1:matrix.org",
body: "Automated response",
sender: "@bot:matrix.org",
origin_server_ts: 1700000000000,
};
const message = adapter.parseMessage(raw);
expect(message.author.isBot).toBe(true);
});
});
```
### Format converter
Test `toAst()` and `fromAst()` for each node type your platform supports, plus `renderPostable()` for all message variants.
```typescript title="src/format-converter.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { MatrixFormatConverter } from "./format-converter";
const converter = new MatrixFormatConverter();
describe("MatrixFormatConverter", () => {
describe("toAst", () => {
it("parses plain text", () => {
const ast = converter.toAst("Hello world");
expect(ast.type).toBe("root");
});
it("parses bold text", () => {
const ast = converter.toAst("**bold**");
// Verify the AST contains a strong node
const paragraph = ast.children[0];
expect(paragraph.children[0].type).toBe("strong");
});
});
describe("fromAst", () => {
it("renders bold text", () => {
const ast = converter.toAst("**bold**");
const result = converter.fromAst(ast);
expect(result).toContain("**bold**");
});
});
describe("renderPostable", () => {
it("renders plain text", () => {
const result = converter.renderPostable({ text: "Hello" });
expect(result).toBe("Hello");
});
it("renders card fallback", () => {
const result = converter.renderPostable({
card: {
type: "card",
title: "Test Card",
children: [],
},
});
expect(result).toContain("Test Card");
});
});
});
```
## Integration testing
Integration tests wire your adapter into a full `Chat` instance and verify end-to-end message handling.
### Test context factory
Create a factory that sets up the full test environment:
```typescript title="src/test-utils.ts" lineNumbers
import { Chat, type Message, type Thread, type ActionEvent, type ReactionEvent } from "chat";
import { createMemoryState } from "@chat-adapter/state-memory";
import { createMatrixAdapter } from "./factory";
interface CapturedMessages {
mentionMessage: Message | null;
mentionThread: Thread | null;
followUpMessage: Message | null;
followUpThread: Thread | null;
}
interface WaitUntilTracker {
waitUntil: (task: Promise<unknown>) => void;
waitForAll: () => Promise<void>;
}
function createWaitUntilTracker(): WaitUntilTracker {
const tasks: Promise<unknown>[] = [];
return {
waitUntil: (task) => {
tasks.push(task);
},
waitForAll: async () => {
await Promise.all(tasks);
tasks.length = 0;
},
};
}
export function createMatrixTestContext(handlers: {
onMention?: (thread: Thread, message: Message) => void | Promise<void>;
onSubscribed?: (thread: Thread, message: Message) => void | Promise<void>;
onAction?: (event: ActionEvent) => void | Promise<void>;
onReaction?: (event: ReactionEvent) => void | Promise<void>;
}) {
const adapter = createMatrixAdapter({
homeserverUrl: "https://matrix.example.com",
accessToken: "syt_test_token",
});
const state = createMemoryState();
const chat = new Chat({
userName: "matrix-bot",
adapters: { matrix: adapter },
state,
logger: "error",
});
const captured: CapturedMessages = {
mentionMessage: null,
mentionThread: null,
followUpMessage: null,
followUpThread: null,
};
if (handlers.onMention) {
const handler = handlers.onMention;
chat.onNewMention(async (thread, message) => {
captured.mentionMessage = message;
captured.mentionThread = thread;
await handler(thread, message);
});
}
if (handlers.onSubscribed) {
const handler = handlers.onSubscribed;
chat.onSubscribedMessage(async (thread, message) => {
captured.followUpMessage = message;
captured.followUpThread = thread;
await handler(thread, message);
});
}
if (handlers.onAction) {
chat.onAction(handlers.onAction);
}
if (handlers.onReaction) {
chat.onReaction(handlers.onReaction);
}
const tracker = createWaitUntilTracker();
return {
chat,
adapter,
state,
tracker,
captured,
sendWebhook: async (fixture: unknown) => {
const request = createSignedMatrixRequest(fixture); // Your signing helper
await chat.webhooks.matrix(request, {
waitUntil: tracker.waitUntil,
});
await tracker.waitForAll();
},
};
}
function createSignedMatrixRequest(payload: unknown): Request {
const body = JSON.stringify(payload);
// Add platform-specific signature headers
return new Request("https://example.com/webhook/matrix", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-matrix-signature": computeSignature(body),
},
body,
});
}
function computeSignature(body: string): string {
// Platform-specific signature computation
return "valid-signature";
}
```
### Writing integration tests
Use the test context to verify the full message flow:
```typescript title="src/integration.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import { createMatrixTestContext } from "./test-utils";
describe("Matrix adapter integration", () => {
it("handles mention → subscribe → follow-up flow", async () => {
const ctx = createMatrixTestContext({
onMention: async (thread) => {
await thread.subscribe();
await thread.post("Got it!");
},
onSubscribed: async (thread, message) => {
await thread.post(`Echo: ${message.text}`);
},
});
// Send a mention
await ctx.sendWebhook({
type: "m.room.message",
room_id: "!room1:matrix.org",
event_id: "$evt1",
body: "@bot hello",
sender: "@alice:matrix.org",
origin_server_ts: Date.now(),
});
expect(ctx.captured.mentionMessage).not.toBeNull();
expect(ctx.captured.mentionMessage?.text).toBe("@bot hello");
// Send a follow-up in the same thread
await ctx.sendWebhook({
type: "m.room.message",
room_id: "!room1:matrix.org",
thread_root_id: "$evt1",
event_id: "$evt2",
body: "follow up",
sender: "@alice:matrix.org",
origin_server_ts: Date.now(),
});
expect(ctx.captured.followUpMessage).not.toBeNull();
expect(ctx.captured.followUpMessage?.text).toBe("follow up");
});
});
```
## What to test end-to-end
Cover these flows in your integration tests:
| Flow | What to verify |
|------|----------------|
| **Mention** | Bot detects @mention, handler fires, `mentionMessage` is captured |
| **Subscribe + follow-up** | After `thread.subscribe()`, subsequent messages trigger `onSubscribedMessage` |
| **Actions** | Button clicks fire `onAction` with correct action ID and user info |
| **Reactions** | Emoji reactions fire `onReaction` with correct emoji and message ID |
| **Self-message filtering** | Messages from the bot itself are ignored |
| **DM flow** | Direct messages are detected and routed correctly |
## Recording and replay tests (advanced)
For production debugging, Chat SDK supports recording webhook interactions and replaying them as test fixtures.
1. **Enable recording** — Set `RECORDING_ENABLED=true` in your deployed environment. Recordings are tagged with the current git SHA.
2. **Interact with the bot** — Send messages, click buttons, add reactions — each interaction is recorded.
3. **Export recordings**
```sh title="Terminal"
pnpm recording:list
pnpm recording:export session-<id>
```
4. **Create test fixtures** — Extract webhook payloads from the exported recording and save them as JSON fixtures:
```json title="fixtures/replay/matrix-mention.json"
{
"botName": "matrix-bot",
"botUserId": "@bot:matrix.org",
"mention": { "type": "m.room.message", "body": "@bot help", "..." : "..." },
"followUp": { "type": "m.room.message", "body": "thanks", "..." : "..." }
}
```
5. **Write replay tests** — Use the fixtures in your test context:
```typescript title="src/replay.test.ts" lineNumbers
import { describe, it, expect } from "vitest";
import fixture from "../fixtures/replay/matrix-mention.json";
import { createMatrixTestContext } from "./test-utils";
describe("replay: mention flow", () => {
it("handles recorded mention interaction", async () => {
const ctx = createMatrixTestContext({
onMention: async (thread) => {
await thread.subscribe();
},
});
await ctx.sendWebhook(fixture.mention);
expect(ctx.captured.mentionMessage).not.toBeNull();
await ctx.sendWebhook(fixture.followUp);
expect(ctx.captured.followUpMessage).not.toBeNull();
});
});
```