An MQTT smoke test answers one question in under a minute: does this broker accept my client and deliver a message right now? Below are three copy-paste scripts — Bash, Python and Node.js — that connect, subscribe, publish a unique token and wait for it to come back. Each prints PASS/FAIL per step and exits with 0 or 1, so the same file works on your laptop, in CI and in a cron job.
What the smoke test proves
| Step | Proves | Typical failure |
|---|---|---|
| Connect | DNS, network path, port, TLS, credentials | Connection refused, not authorised, TLS errors |
| Subscribe | Subscribe ACLs, topic validity | All subscription requests were denied. |
| Publish (QoS 1) | Publish ACLs, broker accepts the message | Timeout waiting for PUBACK |
| Receive | Routing works end to end | Nothing arrives within the timeout |
Every run uses a unique topic under testmqtt/smoke/ and a random token, so parallel runs never see each other's messages and stale retained data cannot produce a false pass. All scripts read the same environment variables:
export MQTT_HOST=broker.connectmq.com # your broker
export MQTT_PORT=8883 # 1883 plain, 8883 TLS
export MQTT_USER=username # optional
export MQTT_PASS=password # optional
export MQTT_TIMEOUT=10 # seconds per stepBash (mosquitto clients)
Needs only mosquitto_pub and mosquitto_sub (apt install mosquitto-clients or brew install mosquitto). The subscriber runs in the background, waits for exactly one message (-C 1) and gives up after the timeout (-W).
#!/usr/bin/env bash
# MQTT smoke test: connect, subscribe, publish, receive. Exit 0 = pass.
set -u
HOST=${MQTT_HOST:-broker.connectmq.com}
PORT=${MQTT_PORT:-1883}
TIMEOUT=${MQTT_TIMEOUT:-10}
TOPIC="testmqtt/smoke/$(hostname -s)-$$-$RANDOM"
TOKEN="smoke-$(date +%s)-$RANDOM"
OUT=$(mktemp); ERR=$(mktemp)
trap 'rm -f "$OUT" "$ERR"' EXIT
ARGS=(-h "$HOST" -p "$PORT" -q 1)
[ -n "${MQTT_USER:-}" ] && ARGS+=(-u "$MQTT_USER" -P "${MQTT_PASS:-}")
[ "$PORT" = 8883 ] && ARGS+=(--capath /etc/ssl/certs)
fail() { echo "FAIL $1"; exit 1; }
command -v mosquitto_sub >/dev/null || fail "mosquitto clients are not installed"
mosquitto_sub "${ARGS[@]}" -t "$TOPIC" -C 1 -W "$TIMEOUT" >"$OUT" 2>"$ERR" &
SUB=$!
sleep 1
kill -0 "$SUB" 2>/dev/null || fail "connect/subscribe: $(cat "$ERR")"
echo "PASS connect + subscribe $HOST:$PORT"
mosquitto_pub "${ARGS[@]}" -t "$TOPIC" -m "$TOKEN" 2>"$ERR" || fail "publish: $(cat "$ERR")"
echo "PASS publish $TOPIC"
wait "$SUB" || fail "receive: nothing within ${TIMEOUT}s $(cat "$ERR")"
grep -qx "$TOKEN" "$OUT" || fail "receive: unexpected payload '$(cat "$OUT")'"
echo "PASS round trip ${SECONDS}s total"A successful run looks like this:
PASS connect + subscribe broker.connectmq.com:8883
PASS publish testmqtt/smoke/build-01-4127-18832
PASS round trip 1s totalThe one-second sleep gives the subscription time to be registered before the publish. It is the only shortcut in this version; the Python and Node scripts wait for the actual SUBACK instead. For more mosquitto_pub/mosquitto_sub options, see mosquitto_pub and mosquitto_sub examples.
Python (paho-mqtt 2.x)
pip install "paho-mqtt>=2". The script waits for each acknowledgement — CONNACK, SUBACK, PUBACK — and checks the reason codes, so a refused login or a denied subscription fails at the right step with the broker's own message.
#!/usr/bin/env python3
"""MQTT smoke test: exit 0 if connect, subscribe, publish and receive all pass."""
import os, sys, threading, time, uuid
import paho.mqtt.client as mqtt
HOST = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
TIMEOUT = float(os.getenv("MQTT_TIMEOUT", "10"))
TOPIC = f"testmqtt/smoke/{uuid.uuid4().hex[:12]}"
TOKEN = uuid.uuid4().hex
connected, subscribed, received = threading.Event(), threading.Event(), threading.Event()
state = {}
def step(ok, name, detail=""):
print(f"{'PASS' if ok else 'FAIL'} {name} {detail}".rstrip())
if not ok:
sys.exit(1)
def on_connect(client, userdata, flags, reason_code, properties):
state["connack"] = reason_code
connected.set()
def on_subscribe(client, userdata, mid, reason_code_list, properties):
state["suback"] = reason_code_list[0]
subscribed.set()
def on_message(client, userdata, msg):
if msg.payload.decode(errors="replace") == TOKEN:
received.set()
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"smoke-{TOKEN[:8]}")
client.on_connect, client.on_subscribe, client.on_message = on_connect, on_subscribe, on_message
if os.getenv("MQTT_USER"):
client.username_pw_set(os.environ["MQTT_USER"], os.getenv("MQTT_PASS", ""))
if PORT == 8883:
client.tls_set()
start = time.perf_counter()
try:
client.connect(HOST, PORT, keepalive=30)
except Exception as exc:
step(False, "connect", f"{HOST}:{PORT} {exc}")
client.loop_start()
ok = connected.wait(TIMEOUT) and not state["connack"].is_failure
step(ok, "connect", f"{HOST}:{PORT} {state.get('connack', 'timeout')}")
client.subscribe(TOPIC, qos=1)
ok = subscribed.wait(TIMEOUT) and not state["suback"].is_failure
step(ok, "subscribe", str(state.get("suback", "timeout")))
info = client.publish(TOPIC, TOKEN, qos=1)
info.wait_for_publish(TIMEOUT)
step(info.is_published(), "publish", TOPIC)
step(received.wait(TIMEOUT), "round trip", f"{(time.perf_counter() - start) * 1000:.0f} ms")
client.disconnect()
client.loop_stop()The network loop runs in a daemon thread, so sys.exit(1) ends the process immediately on the first failing step. Need a refresher on the 2.x callback signatures? See the paho-mqtt 2 tutorial.
Node.js (MQTT.js 5)
npm install mqtt. MQTT.js 5 ships promise-based methods (connectAsync, subscribeAsync, publishAsync, endAsync), which keep the script linear. Because it takes a URL, the same file also tests WebSockets: MQTT_URL=wss://broker.connectmq.com:8084/mqtt.
// MQTT smoke test: node smoke.mjs — exit code 0 = pass
import mqtt from 'mqtt';
import { randomUUID } from 'node:crypto';
const host = process.env.MQTT_HOST ?? 'broker.connectmq.com';
const port = process.env.MQTT_PORT ?? '1883';
const url = process.env.MQTT_URL ?? `${port === '8883' ? 'mqtts' : 'mqtt'}://${host}:${port}`;
const timeout = Number(process.env.MQTT_TIMEOUT ?? 10) * 1000;
const topic = `testmqtt/smoke/${randomUUID().slice(0, 12)}`;
const token = randomUUID();
const step = (ok, name, detail = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name} ${detail}`.trimEnd());
if (!ok) process.exit(1);
};
const within = (promise, what) =>
Promise.race([promise, new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${what} timed out after ${timeout} ms`)), timeout))]);
const start = performance.now();
let client;
try {
client = await within(mqtt.connectAsync(url, {
clientId: `smoke-${token.slice(0, 8)}`,
username: process.env.MQTT_USER,
password: process.env.MQTT_PASS,
reconnectPeriod: 0,
connectTimeout: timeout,
}, false), 'connect');
step(true, 'connect', url);
} catch (err) {
step(false, 'connect', `${url} ${err.message}`);
}
const received = new Promise((resolve) => {
client.on('message', (t, payload) => {
if (t === topic && payload.toString() === token) resolve();
});
});
try {
const [grant] = await within(client.subscribeAsync(topic, { qos: 1 }), 'subscribe');
step(grant.qos < 128, 'subscribe', `granted QoS ${grant.qos}`);
await within(client.publishAsync(topic, token, { qos: 1 }), 'publish');
step(true, 'publish', topic);
await within(received, 'receive');
step(true, 'round trip', `${Math.round(performance.now() - start)} ms`);
} catch (err) {
step(false, 'step', err.message);
}
await client.endAsync();A granted QoS of 128 (0x80) means the broker refused the subscription, usually because of an ACL.
Run it in CI and on a schedule
Because the scripts exit non-zero on failure, they drop straight into any pipeline. A GitHub Actions step:
- name: MQTT smoke test
env:
MQTT_HOST: broker.connectmq.com
MQTT_PORT: "8883"
MQTT_USER: ${{ secrets.MQTT_USER }}
MQTT_PASS: ${{ secrets.MQTT_PASS }}
run: |
sudo apt-get update && sudo apt-get install -y mosquitto-clients
bash smoke.shRun it after every deploy that touches the broker, its certificates or its ACLs, and on a schedule (for example every five minutes) as a synthetic check. MQTT testing in GitHub Actions shows a full workflow with a broker service container.
Reading a failure
| Failing step | Look at first |
|---|---|
connect, Connection refused / timeout | Host, port, firewall; is the listener running? |
connect, not authorised / bad user name or password | Credentials, or anonymous access disabled |
| connect, TLS / certificate error | Port 8883 vs 1883, CA bundle, hostname in the certificate |
| subscribe denied, granted QoS 128 | ACL for the test user's subscribe rights |
| publish times out | Publish ACL (MQTT 5 returns 0x87), broker overload |
| round trip times out, earlier steps pass | Topic mismatch, publish ACL silently dropping (MQTT 3.1.1), bridge or cluster routing |
Every connection error and its fixes are covered in MQTT connection errors; reason codes can be decoded with the MQTT reason codes tool. If you want a person to repeat the check by hand, the online MQTT client performs the same connect–subscribe–publish cycle in a browser.
Watch smoke-test messages live
A smoke test is the cheapest MQTT test you can write and the one you will run most often. Keep it short, make every run unique, fail loudly with the step that broke, and wire it into CI and a schedule; the deeper checks in the MQTT broker test checklist can build on it.
Frequently asked questions
What should an MQTT smoke test check?
The minimum is a full round trip: connect with real credentials, subscribe, publish a unique token and receive that exact token back within a timeout. That one path exercises DNS, the network, TLS, authentication, ACLs and message routing.
How is a smoke test different from a health check?
A health check usually confirms that the broker is up, often by connecting or reading a metric. A smoke test proves the broker does its job by sending a real message end to end. Many teams run the smoke test as their health check.
Why does my smoke test fail only sometimes?
The most common cause is publishing before the subscription is acknowledged, so the first message is missed. Wait for SUBACK before publishing, use QoS 1 and a unique topic per run, and give the receive step a realistic timeout.



