Deinen Bot testen¶

bostie.testing betreibt ein Fake-Discord auf localhost: ein Gateway, eine REST-API mit den Routen, die ein Bot am häufigsten nutzt, und optional einen Sprachserver. Dein echter Bot verbindet sich damit, und der Test steuert Discords Seite: Er sendet Events und Interaktionen und prüft dann, was der Bot zurückgesendet hat. Es braucht das Extra testing (Installation), und bostie selbst lässt seine eigene Testsuite darauf laufen.
from bostie import Bot, ComponentContext, Context, ui
from bostie.events import BotEvent, MessageCreate
from bostie.testing import FakeDiscord, payloads, run, running
INTERACTION_COMMAND = 2
INTERACTION_COMPONENT = 3
class Counted(BotEvent, kw_only=True):
count: int
def make_bot(api_url: str) -> Bot:
bot = Bot(api_url=api_url)
seen: list[str] = []
@bot.command
async def hello(ctx: Context) -> None:
"""Say hello."""
wave = ui.button("Wave", custom_id="wave")
await ctx.respond("Hello!", components=[ui.row(wave)])
@bot.on_component("wave")
async def wave(ctx: ComponentContext) -> None:
await ctx.update(content="*waves*")
@bot.listen
async def count(event: MessageCreate) -> None:
seen.append(event.message.content)
await bot.rest.messages.create(event.message.channel_id, f"{len(seen)} seen")
bot.dispatch(Counted(count=len(seen)))
return bot
def test_hello_answers_and_the_button_edits_the_message() -> None:
async def scenario(fake: FakeDiscord) -> None:
async with running(make_bot(fake.api_url), fake):
reply = await fake.interaction(
INTERACTION_COMMAND, payloads.command("hello")
)
update = await fake.interaction(
INTERACTION_COMPONENT, payloads.button("wave")
)
assert reply.json["data"]["content"] == "Hello!"
assert update.json["data"]["content"] == "*waves*"
run(scenario)
def test_each_message_is_counted_out_loud() -> None:
async def scenario(fake: FakeDiscord) -> None:
bot = make_bot(fake.api_url)
async with running(bot, fake), bot.stream(Counted) as counted:
await fake.dispatch("MESSAGE_CREATE", payloads.message(content="hi"))
first = await anext(counted)
sent = [r.json for r in fake.requests if r.path.endswith("/messages")]
assert first.count == 1
assert [message["content"] for message in sent] == ["1 seen"]
run(scenario)
run(scenario) startet ein FakeDiscord, führt async def scenario(fake) innerhalb von 10 Sekunden dagegen aus und fährt es wieder herunter; es ist eine gewöhnliche Funktion, pytest braucht also kein Plugin. async with running(bot, fake): startet den Bot am Fake, wartet, bis er bereit ist, und schließt ihn am Ende. Richte den Bot mit Bot(api_url=fake.api_url) auf das Fake aus, oder setz BOSTIE_API_URL, wenn der Bot woanders gebaut wird.
Discords Seite steuern¶
| Aufruf | Bewirkt |
|---|---|
await fake.interaction(type, data) |
sendet ein INTERACTION_CREATE und gibt die erste Antwort des Bots als Recorded-Anfrage zurück |
await fake.dispatch(name, d, shard=0) |
sendet ein beliebiges Gateway-Event und gibt seine Sequenznummer zurück |
fake.expect_callback(payload) |
für eine Interaktion, die du selbst sendest: ein Future für ihre erste Antwort |
await fake.send_op(op, d) |
sendet einen Opcode, der kein Dispatch ist, etwa 7 (Reconnect) oder 9 (ungültige Sitzung) |
await fake.close_gateway(code), fake.drop_gateway() |
schließt die Verbindung mit einem Close-Code oder bricht sie ohne ab |
fake.hold_acks() |
beantwortet keine Heartbeats mehr, wie es eine tote Verbindung täte |
await fake.next_identify(), await fake.next_resume() |
wartet auf das nächste IDENTIFY oder RESUME des Bots |
Interaktionstypen sind Discords Nummern: 2 für einen Befehl, 3 für eine Komponente, 4 für die Autovervollständigung, 5 für ein abgesendetes Modal.
bostie.testing.payloads baut Discords Payloads aus den Beispielen seiner Dokumentation, mit frischen IDs:
command(name, *options),option(name, type, value, focused=False),subcommand(...),group(...)undresolved()für Befehle und die Autovervollständigung;context_menu(type, name, target_id, resolved)für Benutzer- und Nachrichtenbefehle;button(custom_id),select(custom_id, *values)undmodal(custom_id)für Komponenten und Modals;message(...),guild(...),member(...),user(...),role(...),channel(...),voice_channel(...),stage_channel(...)undvoice_state(...)für Events, jeweils mit Schlüsselwortargumenten, die Felder überschreiben;fixture(name)für jeden dokumentierten Payload, den bostie mitliefert, undsnowflake()für eine neue ID.
Prüfen, was der Bot gesendet hat¶
fake.requests listet jede REST-Anfrage als Recorded, und fake.expect_request(method, route_prefix) gibt ein Future für die nächste passende zurück, für eine Anfrage, die der Bot von sich aus stellt, etwa aus einem Task. Ein Recorded hat method, path (relativ zur API-Basis), query, headers, json und bei Uploads parts. Eine Recorded-Antwort auf eine Interaktion hat den Callback-Body in json, wie oben. fake.gateway_received ist eine Warteschlange der Payloads, die der Bot über das Gateway gesendet hat, und fake.connections die Gateway-Verbindungen, Connection-Objekte mit ihren received-Payloads, shard und close_code.
fake.cdn_file(path, data) stellt data unter einer CDN-URL bereit, die es zurückgibt, für einen Anhang oder ein Asset, das der Bot herunterlädt, und fake.cdn_requests zeichnet diese Downloads auf.
Das Fake hält den Zustand, den diese Routen brauchen, und Tests können ihn lesen oder vorbelegen: fake.guilds, fake.channels, fake.users, fake.members, fake.messages, fake.commands und fake.guild_commands, dazu fake.put_count für Befehlssynchronisierungen. fake.ready_guilds legt die Server-IDs fest, die READY aufführt.
Fehler und Timing¶
fake.fail(method, route_prefix, status, json)beantwortet die nächste passende Anfrage mit einem Fehler und ersetzt eine Route, die das Fake nicht bedient.fake.rate_limit(method, route_prefix, scope="user", retry_after=0.1)beantwortet sie mit einem 429.fake.answer(method, route_prefix, body, content_type)beantwortet sie mit einem Body, der kein JSON ist.fake.hold(method, route_prefix)lässt sie warten, bis du das zurückgegebeneasyncio.Eventsetzt;fake.hold_identify(shard)undfake.hold_hello()tun dasselbe für den Gateway-Handshake.await settle()gibt einige Male an die Event-Loop ab, damit Callbacks laufen, die durch das, was du gerade getan hast, eingeplant wurden.
Um auf etwas zu warten, das der Bot in einem Listener tut, lass ihn ein eigenes Event senden und warte darauf, wie es der zweite Test tut, statt zu schlafen.
FakeDiscord(compress=True) komprimiert das Gateway mit zstd, was compression.zstd aus Python 3.14 braucht; heartbeat_interval legt das Intervall fest, das es verlangt, standardmäßig Discords 41 250 ms.
Sprachkanäle¶
FakeDiscord(voice=True) fügt einen Sprachkanal und einen Stage-Kanal hinzu, beantwortet Updates des Sprachzustands und betreibt ein Sprach-Gateway und einen UDP-Server als fake.voice, sodass ctx.voice.connect() und vc.play(...) dagegen funktionieren. Es spricht kein DAVE; das decken die Rust-Tests von bostie ab. Der Test kann:
- mit
await fake.next_voice_state_update(),await fake.voice.next_identify()undawait fake.voice.wait_for_packets(count)auf das warten, was der Bot gesendet hat; Letzteres gibt die entschlüsselten RTP-Pakete alsPacket-Objekte zurück. Jede Sprachverbindung ist eineVoiceSessioninfake.voice.sessions, auf einemVoiceSocket, undfake.voice.op(op, d)sendet der neuesten einen beliebigen Sprach-Opcode; - als andere Benutzer handeln, mit
fake.voice.clients_connect(user_ids),fake.voice.client_disconnect(user_id),fake.voice.speaking(user_id, ssrc, flags),fake.voice.send_audio(...), das Opus-Pakete an den Bot sendet,fake.voice.send_rtcp(packet), das ihm ein RTCP-Paket sendet, undfake.voice.send_datagram(data), das Bytes unversiegelt sendet, wie es ein defekter Server täte; - den Bot verschieben oder trennen, wie es ein Moderator täte, mit
fake.move_bot(channel_id)undfake.kick_bot(); - mit
fake.hold_voice_state()das nächste Update des Sprachzustands unbeantwortet lassen, wie Discord es bei einem vollen Kanal tut, bis das zurückgegebeneasyncio.Eventgesetzt wird; fake.mute,fake.deafundfake.suppressfür die Sprachzustände setzen, die es über den Bot sendet, undvoice_orderfür die Reihenfolge der beiden Events, die Discord beim Beitreten sendet.