Broker Testing

A One-Minute MQTT Smoke Test Script (Bash, Python, Node)

Copy-paste MQTT smoke test scripts in Bash, Python and Node.js that connect, subscribe, publish and receive, then print PASS or FAIL with a CI-ready exit code.

Updated · 8 min read

A One-Minute MQTT Smoke Test Script (Bash, Python, Node)

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

StepProvesTypical failure
ConnectDNS, network path, port, TLS, credentialsConnection refused, not authorised, TLS errors
SubscribeSubscribe ACLs, topic validityAll subscription requests were denied.
Publish (QoS 1)Publish ACLs, broker accepts the messageTimeout waiting for PUBACK
ReceiveRouting works end to endNothing 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:

Configuration
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 step

Bash (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).

smoke.sh
#!/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:

Output
PASS  connect + subscribe  broker.connectmq.com:8883
PASS  publish  testmqtt/smoke/build-01-4127-18832
PASS  round trip  1s total

The 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.

smoke.py
#!/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.

smoke.mjs
// 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:

.github/workflows/smoke.yml (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.sh

Run 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 stepLook at first
connect, Connection refused / timeoutHost, port, firewall; is the listener running?
connect, not authorised / bad user name or passwordCredentials, or anonymous access disabled
connect, TLS / certificate errorPort 8883 vs 1883, CA bundle, hostname in the certificate
subscribe denied, granted QoS 128ACL for the test user's subscribe rights
publish times outPublish ACL (MQTT 5 returns 0x87), broker overload
round trip times out, earlier steps passTopic 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.