@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
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.
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 samesenderId for the same person, whatever
alias the transport uses (see the identity contract on IProvider). These
helpers check a list of emitted events:
- ids are non-empty and do not look like
undefined/null/NaN/[object Object]; from/chatId/chat.id,sender/senderId/senderInfo.idandref.chatIdagree;ChatKindis valid and consistent withisGroup/isBroadcast, and a chat never changes kind;- per person: one
senderId, one DMchatId, and the expected ids/kind; - two different people never share a
senderId.
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 itstests/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
- Terminal simulator: try the bot by hand
- Platform sandbox: test a real provider end to end against a local simulator of its platform
- Inspect & lint flows