Skip to main content
@botmesh/core/testing lets you test a bot without WhatsApp, a phone or the network. It works with any test runner; the examples use Vitest.

A first test

Keep your handlers and flows in a function that takes the bot, so the real bot and the tests register exactly the same thing:
expectReply rejects with a readable TestKitError when the reply does not arrive, so a failing test tells you what the bot sent instead.

createTestBot and MockProvider

createTestBot(config?) builds a started Bot on a MockProvider. To test the bot module you ship, pass its defineBot factory instead: await createTestBot(createBot) calls it with the MockProvider and starts the bot it returns (it throws if the factory built the bot on another provider). chat is one DM ([email protected]): its helpers simulate what the user does and assert on what the bot sent back. Every send*/press* helper yields one macrotask before resolving, so the dispatch has started. The helpers throw TestKitError with an actionable message. expectReply and expectNoReply poll with real timers: under vi.useFakeTimers() advance them (vi.advanceTimersByTimeAsync) or leave setTimeout/setInterval unfaked. You can also drive the mock directly: provider.incoming(text, { from, sender, ... }), provider.incomingMedia(kind, bytes, opts), provider.interaction(data, { kind }) (button and list taps), provider.incomingLocation(location, opts), provider.reaction(emoji, { to }) and provider.call({ id, from, status }) (for CALL triggers). provider.journal keeps inbound messages, interactions and sends (with their ref) in order, bounded by maxJournal (10 000 by default). provider.acks records every interaction ack. new MockProvider({ interactionsRequireAck: true }) simulates a transport with a hard ack deadline (Discord), where the core acks each interaction on its own; pass provider.interaction(data, { respondBy }) to set the deadline. editMessage, deleteMessage and sendDraft are real methods (recorded in edits, deleted and drafts) that you can override in a subclass. They are exposed according to new MockProvider({ streaming, supports }): edit and delete when streaming is not 'none', drafts with 'draft'. A disabled one reads undefined, so the bot treats it as unsupported; pass supports: { edit: true } to enable one, or supports: { edit: false } to simulate a transport without it (this also hides a subclass override).

Several users and groups

Each view carries chatId, senderId, isGroup and displayName. Views of the same chat share one reply cursor (group replies are not per sender), so participants read the chat’s replies in order, while conversations stay per participant (conversationScope: 'participant'). On a DM, chat.as(userId) is another DM. provider.journal holds the events every view emitted, ready for assertStableIdentity.

Conversation snapshots

chat.transcript({ name?, ids?, timestamps?, scope?, as? }) returns a golden transcript with the shape recordTranscript produces: it parses, redacts (redactTranscript) and replays (assertTranscript) unchanged. Ids and t offsets are dropped by default so snapshots stay stable; scope: 'all' interleaves every chat the mock saw and as: 'text' renders one line per turn. Reactions are not transcript turns.

Vitest matchers

@botmesh/core/testing/vitest exports the matchers and augments vitest’s Matchers type (.not works). vitest is an optional peer dependency: nothing is imported from it at runtime. The matchers are synchronous: wait with expectReply, then assert. MockProvider always uses a stable from. To test identity changes and hostile delivery, use the chaos kit below.

Chaos kit: adversarial delivery

ChaosProvider wraps any IProvider (usually MockProvider) and delivers its traffic adversarially: duplicates, reordering, latency, failed sends, disconnects and identity aliases. It is deterministic for a given seed.
With any provider: const chaos = withChaos(myProvider, { seed: 1, duplicates: 1 }); new Bot({ provider: chaos }). Manual controls: chaos.disconnect({ reason, downtimeMs }) (without downtimeMs it stays down until chaos.reconnect()), chaos.replay(n) (an explicit webhook retry), chaos.flush() and chaos.settle(). For assertions: chaos.log (what was injected) and chaos.delivered (what reached the bot). For exact timing use vi.useFakeTimers() and vi.advanceTimersByTimeAsync(). Since inbound dedupe is on by default, a bot under duplicates and replay should still answer each message once.

Identity contract for providers

Every provider must emit the same senderId for the same person, whatever alias the transport uses (see the identity contract on IProvider). These helpers check a list of emitted events:
What it checks:
  • ids are non-empty and do not look like undefined/null/NaN/[object Object];
  • from/chatId/chat.id, sender/senderId/senderInfo.id and ref.chatId agree;
  • ChatKind is valid and consistent with isGroup/isBroadcast, and a chat never changes kind;
  • per person: one senderId, one DM chatId, and the expected ids/kind;
  • two different people never share a senderId.
Options: requireRefChatId, rejectSuspiciousIds, distinctHumans. assertStableIdentity throws an IdentityContractError with violations. A real example lives in packages/baileys-provider/tests/identity-contract.test.ts.

Provider contracts

Three more checks keep every channel consistent (see the cross-channel contract). Each built-in provider runs them in its tests/channel-contract.test.ts.
checkInboundContract(events, { now?, maxFutureSkewMs?, minTimestamp? }) returns the violations (timestamp-missing, timestamp-unit, timestamp-future, respond-by, body, mentions, message-type); assertInboundContract throws an InboundContractError. checkCapabilityContract / assertCapabilityContract do the same for capabilities (CapabilityContractError). MockProvider passes both: it emits epoch ms, and its typing/readReceipts flags follow whether a typing()/markRead() method exists (a subclass or a test that adds one gets the flag). checkPresenceWhileDisconnected / assertPresenceWhileDisconnected report the rule presence-disconnected when typing() or markRead() rejects while not connected.

Golden transcripts

To lock down whole conversations, record one (recordTranscript) or read it from the store log (transcriptFromStore), redact it, commit it as JSON and replay it in a test with assertTranscript: any changed, missing or extra reply fails with an expected-vs-actual diff. The same replay can gate a deployment. See Golden transcripts.

Static checks

eslint-plugin-botmesh catches v1 API leftovers, a blocking runtime.enter(), async plugin install(), empty triggers, waits without a timeout and per-user state in module variables before the code runs.

Next steps