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

## 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();
  });
});
```
