Testing an MQTT service
A test runs the service's own app with TestApp, against an in-process MQTT server or against Mosquitto.
The service under test watches freezers.
Each freezer publishes its temperature to freezers/<id>/temperature, and the monitors share the readings in a share group.
Every reading goes into a temperature log.
A freezer warmer than -18 °C raises an alert on freezers/alerts.
When the log does not answer, the handler asks for the reading again in ten seconds with retry_after.
After three deliveries a reading goes to freezers/review, the dead-letter topic.
The handler watch publishes the alert through an Alerts slot.
The app is built by service(broker), so a test chooses the broker and main uses the production one.
The tests need the testing feature of ruststream-rumqttc in [dev-dependencies].
A warm freezer
- Macros
- Manual
#[tokio::test]
async fn a_warm_freezer_raises_an_alert() -> Result<(), Box<dyn Error + Send + Sync>> {
let broker = MqttBroker::new("mqtt://localhost:1883", "cold-chain");
let tb = TestApp::start(service(broker)).await?;
a_warm_reading_raises_one_alert(tb).await
}
async fn a_warm_reading_raises_one_alert(
tb: TestApp<()>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
tb.broker::<MqttBroker>()
.message(&reading("f1", -12.5))
.to("freezers/f1/temperature")
.publish()
.await?;
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.assert_called_once()
.settled(HandlerOutcome::ack());
tb.broker::<MqttBroker>()
.published::<Alert>("freezers/alerts")
.assert_called_once()
.with(&Alert {
freezer: "f1".to_owned(),
celsius: -12.5,
});
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn a_warm_freezer_raises_an_alert() -> Result<(), Box<dyn Error + Send + Sync>> {
let broker = MqttBroker::new("mqtt://localhost:1883", "cold-chain");
let tb = TestApp::start(service(broker)).await?;
a_warm_reading_raises_one_alert(tb).await
}
async fn a_warm_reading_raises_one_alert(
tb: TestApp<()>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
tb.broker::<MqttBroker>()
.message(&reading("f1", -12.5))
.to("freezers/f1/temperature")
.publish()
.await?;
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.assert_called_once()
.settled(HandlerOutcome::ack());
tb.broker::<MqttBroker>()
.published::<Alert>("freezers/alerts")
.assert_called_once()
.with(&Alert {
freezer: "f1".to_owned(),
celsius: -12.5,
});
tb.shutdown().await?;
Ok(())
}
TestApp::start connects MqttBroker to an in-process MQTT server.
The URL is never dialled.
The test addresses the broker by its production type, tb.broker::<MqttBroker>().
A Reading names no topic, so the test names one with .to("freezers/f1/temperature"), as a freezer would.
The handler's filter matches it the way Mosquitto would.
publish returns once the handler and its alert have settled.
So the next lines assert on finished state: how the subscription settled and what reached freezers/alerts.
The subscription is addressed by its filter, without the share group.
The body takes the started app as an argument, so the live test below runs it too.
A cold freezer
- Macros
- Manual
#[tokio::test]
async fn a_cold_freezer_raises_no_alert() -> Result<(), Box<dyn Error + Send + Sync>> {
let broker = MqttBroker::new("mqtt://localhost:1883", "cold-chain");
let tb = TestApp::start(service(broker)).await?;
tb.broker::<MqttBroker>()
.message(&reading("f1", -21.0))
.to("freezers/f1/temperature")
.publish()
.await?;
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.assert_called_once()
.settled(HandlerOutcome::ack());
tb.out::<Alerts>().assert_not_called();
tb.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn a_cold_freezer_raises_no_alert() -> Result<(), Box<dyn Error + Send + Sync>> {
let broker = MqttBroker::new("mqtt://localhost:1883", "cold-chain");
let tb = TestApp::start(service(broker)).await?;
tb.broker::<MqttBroker>()
.message(&reading("f1", -21.0))
.to("freezers/f1/temperature")
.publish()
.await?;
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.assert_called_once()
.settled(HandlerOutcome::ack());
tb.out::<Alerts>().assert_not_called();
tb.shutdown().await?;
Ok(())
}
settled checks the outcome the handler returned.
tb.out::<Alerts>() reads what the handler published through its Alerts slot, and assert_not_called checks that it published nothing.
A delay, without waiting for it
- Macros
- Manual
#[tokio::test(start_paused = true)]
async fn a_reading_the_log_keeps_refusing_goes_to_review()
-> Result<(), Box<dyn Error + Send + Sync>> {
let broker = MqttBroker::new("mqtt://localhost:1883", "cold-chain");
let tb = TestApp::start(service(broker)).await?;
let refused = reading("f7", -20.0);
tb.broker::<MqttBroker>()
.message(&refused)
.to("freezers/f7/temperature")
.publish()
.await?;
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.assert_called_once()
.settled(HandlerOutcome::retry_after(RETRY));
// The delay is a copy of the reading, published to the retry topic once it has passed.
tb.advance(RETRY).await?;
tb.broker::<MqttBroker>()
.published::<Reading>("freezers/retry/temperature")
.assert_called_once()
.with(&refused)
.with_header(RETRY_COUNT_HEADER, "1");
// The third delivery is the last one: it goes to review as it arrived.
tb.advance(RETRY).await?;
assert_eq!(
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.outcomes(),
[Outcome::Nack, Outcome::Nack, Outcome::Nack],
);
tb.broker::<MqttBroker>()
.published::<Reading>("freezers/review")
.assert_called_once()
.with(&refused);
tb.shutdown().await?;
Ok(())
}
#[tokio::test(start_paused = true)]
async fn a_reading_the_log_keeps_refusing_goes_to_review()
-> Result<(), Box<dyn Error + Send + Sync>> {
let broker = MqttBroker::new("mqtt://localhost:1883", "cold-chain");
let tb = TestApp::start(service(broker)).await?;
let refused = reading("f7", -20.0);
tb.broker::<MqttBroker>()
.message(&refused)
.to("freezers/f7/temperature")
.publish()
.await?;
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.assert_called_once()
.settled(HandlerOutcome::retry_after(RETRY));
// The delay is a copy of the reading, published to the retry topic once it has passed.
tb.advance(RETRY).await?;
tb.broker::<MqttBroker>()
.published::<Reading>("freezers/retry/temperature")
.assert_called_once()
.with(&refused)
.with_header(RETRY_COUNT_HEADER, "1");
// The third delivery is the last one: it goes to review as it arrived.
tb.advance(RETRY).await?;
assert_eq!(
tb.broker::<MqttBroker>()
.subscriber("freezers/+/temperature")
.outcomes(),
[Outcome::Nack, Outcome::Nack, Outcome::Nack],
);
tb.broker::<MqttBroker>()
.published::<Reading>("freezers/review")
.assert_called_once()
.with(&refused);
tb.shutdown().await?;
Ok(())
}
The test starts on a paused clock, and advance moves it forward.
Ten seconds of RETRY cost nothing.
retry_after publishes a copy of the reading to freezers/retry/temperature when the delay ends, and the filter reads it again.
The copy carries the retry count in a header, and published shows it.
The third delivery is the last one the mount site allows, so the reading goes to freezers/review as it arrived.
Each delayed delivery is recorded as Outcome::Nack.
What the in-process server models
The in-process server reads the broker's own settings, and a publish goes through the same validation and the same mapping onto MQTT 5 properties as on the wire. It models the server one connection talks to:
- A publish reaches every subscription whose filter matches its topic, with
+,#and the rule for topics that start with$. - A share group takes one copy between its members, and overlapping filters each receive their own.
- A retained message reaches a later subscription outside a share group, and an empty retained payload clears it.
- A message larger than
max_packet_sizenever arrives, and a topic or header the client refuses fails the publish. - An acknowledgement after shutdown is refused.
Each broker connected in process is a server of its own.
A persistent session and its resumption, the last will, the receive-maximum window, the QoS 1 and 2 handshakes and several connections on one server need a real broker.
The same test on Mosquitto
- Macros
- Manual
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_warm_freezer_raises_an_alert_on_mosquitto()
-> Result<(), Box<dyn Error + Send + Sync>> {
let Some(url) = live::url("MQTT_TEST_URL") else {
return Ok(());
};
let broker = MqttBroker::new(url, live::client_id("cold-chain"));
let tb = TestApp::start_live(service(broker)).await?;
a_warm_reading_raises_one_alert(tb).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_warm_freezer_raises_an_alert_on_mosquitto()
-> Result<(), Box<dyn Error + Send + Sync>> {
let Some(url) = live::url("MQTT_TEST_URL") else {
return Ok(());
};
let broker = MqttBroker::new(url, live::client_id("cold-chain"));
let tb = TestApp::start_live(service(broker)).await?;
a_warm_reading_raises_one_alert(tb).await
}
TestApp::start_live connects the same app to a running server.
The test reads the address from MQTT_TEST_URL with live::url, a helper of the example's own, and returns at once when it is unset.
It connects with a client id of its own, so it shares the server with other runs.
The assertions are the in-process ones, unchanged.
cargo test
MQTT_TEST_URL=mqtt://localhost:1883 cargo test -- --test-threads=1
RUSTSTREAM_REQUIRE_LIVE=1 turns the skip into a failure, for a CI job that starts a server and must not pass without it.