blackbull.testing.mqtt¶
blackbull.testing.mqtt
¶
In-process MQTT test environment — drive on_message taps with no socket.
Testing a @mqtt.on_message tap otherwise means standing up a real broker
and a real MQTT client (mosquitto_pub). This
module feeds PUBLISHes into the app's registered taps directly — topic
matching and {name} captures included — with no TCP socket, no CONNECT,
and no MQTT client::
from blackbull import BlackBull
from blackbull.mqtt import MQTTExtension, Message
from blackbull.testing.mqtt import MQTTTestBroker
app = BlackBull()
mqtt = app.add_extension(MQTTExtension(port=1883))
captured = []
@mqtt.on_message(topic='sensors/{room}/temperature')
async def on_temp(msg: Message, room: str):
captured.append((room, msg.payload))
async def test_tap():
async with MQTTTestBroker(app) as broker:
await broker.publish(topic='sensors/room1/temperature', payload=b'21.5')
assert captured == [('room1', b'21.5')]
publish dispatches to the matching taps inline and awaits their
completion, so the test asserts on the side effect the moment publish
returns. A tap that raises propagates its exception out of publish —
where production logs and isolates a failing tap (taps are best-effort
observers), a test wants the failure visible. The extension's own
tap_mode is irrelevant here: a test wants determinism, not the production
decoupling.
This is the application-handler-level counterpart of the conformance suite's
in-process fake-reader harness (tests/conformance/mqtt/conftest.py) — that
drives the wire codec, this drives the taps. Only the tap pipeline is
exercised: broker routing, QoS flows and retained messages are the
conformance suite's territory, not this helper's.
MQTTTestBroker
¶
Test environment for an app's MQTT taps (no socket, no client).
Parameters¶
app: The BlackBull app the MQTT extension is registered on.
The helper is async-only by design: taps are coroutines and
publish awaits them on the caller's event loop, so tests are written
as async def (the suite runs with asyncio_mode = strict). A
synchronous façade like :class:blackbull.testing.native.NativeClient is
deliberately not provided — there is no background loop to bridge to, and
no socket or broker that would need one.
The async with form is ceremony: the broker binds no socket, starts no
task and owns no resource, so entering and exiting are no-ops. It is kept
for a consistent test idiom, and so a later version can acquire state here
without changing call sites.
publish(topic, payload=b'', *, qos=0, retain=False, properties=None)
async
¶
Dispatch one PUBLISH to every matching tap and await them.
topic is matched against the registered filters — + / #
wildcards and {name} captures apply — and captured segments are
bound as keyword arguments. Taps run inline and are complete when
this returns.
A tap that raises propagates its exception, and the remaining taps in
this publish are skipped — the first-failure semantics a
sequential test loop already has. Production, by contrast, logs and
isolates a raising tap; publish deliberately does not.
qos, retain and properties are passed through to the
:class:~blackbull.mqtt.Message without validation, so a test may
exercise values the wire layer would reject. Wire-level QoS
validation is the conformance suite's territory, not this helper's.