Tests de brokers

Tester les fonctionnalités MQTT 5 : user properties, expiry, topic aliases

Tests pratiques MQTT 5 avec MQTT.js et paho-mqtt : user properties, expiration des messages et sessions, topic aliases, request/response et no-local.

Mis à jour · 10 min de lecture

Tester les fonctionnalités MQTT 5 : user properties, expiry, topic aliases

MQTT 5 a ajouté des fonctionnalités qui changent le comportement des applications — métadonnées dans les user properties, messages qui expirent, paquets plus courts grâce aux topic aliases, sessions avec une durée de vie. Chacune suppose que la bibliothèque cliente et le broker l'implémentent correctement : testez les fonctionnalités MQTT 5 de bout en bout avant de vous appuyer dessus.

Cet article fournit de petits tests exécutables avec MQTT.js 5 (Node.js) et paho-mqtt 2.x (Python). Chaque test publie, observe ce qui arrive et vérifie le résultat par assertion : il tourne aussi bien en CI que sur votre poste.

Quoi tester, et ce que « réussi » veut dire

FonctionnalitéTestCondition de réussite
User propertiesPublier avec des paires clé/valeurL'abonné reçoit les mêmes paires
Content type, correlation dataPublier avec les deux définisL'abonné reçoit les deux inchangés
Message expiry (retenu)Message retenu avec expiry de 3 s, abonnement après 5 sRien n'est livré
Message expiry (en file)Mettre en file des messages pour une session hors ligneLe message non expiré arrive avec un intervalle réduit ; l'expiré est supprimé
Session expirySe reconnecter avec clean_start=Falsesession present vaut true et les messages en file arrivent
Topic aliasesEnregistrer un alias puis publier avec un topic videL'abonné voit le nom de topic complet
No localS'abonner et publier sur la même connexion avec nlLe client ne reçoit pas son propre message

Commencez par vérifier que le broker accepte les connexions MQTT 5 et consultez ses limites dans le CONNACK (topic alias maximum, maximum QoS) avec la sonde de fonctionnalités du broker. Un test qui échoue parce que le broker annonce TopicAliasMaximum 0 relève de la configuration, pas d'un bug.

Mise en place

Installer les deux bibliothèques
npm install mqtt@5
pip install "paho-mqtt>=2.0"

Tous les exemples utilisent broker.connectmq.com:1883 et un préfixe de topic aléatoire sous testmqtt/mqtt5/ pour que des exécutions parallèles ne voient pas les messages des autres. Ajoutez username et password ($MQTT_USER / $MQTT_PASS) si votre broker les exige.

MQTT.js : propriétés, expiry, alias et no-local

Le script ci-dessous exécute quatre tests à la suite et se termine avec un code non nul au premier échec. nextMessage attend un message ou expire, ce qui rend « rien ne doit arriver » aussi simple à vérifier que « ceci doit arriver ».

mqtt5.test.mjs
import mqtt from 'mqtt';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';

const URL = 'mqtt://broker.connectmq.com:1883';
const opts = { protocolVersion: 5, username: process.env.MQTT_USER, password: process.env.MQTT_PASS };
const base = `testmqtt/mqtt5/${randomUUID().slice(0, 8)}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const nextMessage = (client, timeoutMs = 5000) =>
  new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
    client.once('message', (topic, payload, packet) => {
      clearTimeout(timer);
      resolve({ topic, payload, packet });
    });
  });

const sub = await mqtt.connectAsync(URL, opts);
const pub = await mqtt.connectAsync(URL, opts);

// 1. User properties, content type, correlation data
await sub.subscribeAsync(`${base}/props`, { qos: 1 });
let incoming = nextMessage(sub);
await pub.publishAsync(`${base}/props`, '{"temp":21.5}', {
  qos: 1,
  properties: {
    userProperties: { source: 'ci', 'trace-id': 'abc123' },
    contentType: 'application/json',
    responseTopic: `${base}/reply`,
    correlationData: Buffer.from('req-42'),
  },
});
const { packet } = await incoming;
assert.equal(packet.properties.userProperties.source, 'ci');
assert.equal(packet.properties.userProperties['trace-id'], 'abc123');
assert.equal(packet.properties.contentType, 'application/json');
assert.equal(packet.properties.correlationData.toString(), 'req-42');
console.log('PASS user properties / content type / correlation data');

// 2. Retained message expiry
await pub.publishAsync(`${base}/expiry`, 'short-lived', {
  qos: 1, retain: true, properties: { messageExpiryInterval: 3 },
});
await sleep(5000);
const late = await mqtt.connectAsync(URL, opts);
incoming = nextMessage(late, 3000);
await late.subscribeAsync(`${base}/expiry`, { qos: 1 });
await assert.rejects(incoming, /timeout/, 'expired retained message was delivered');
console.log('PASS retained message expired');

// 3. Topic alias: register alias 1, then publish with an empty topic
const aliasClient = mqtt.connect(URL, opts);
const connack = await new Promise((resolve, reject) => {
  aliasClient.once('connect', resolve);
  aliasClient.once('error', reject);
});
const aliasMax = connack.properties?.topicAliasMaximum ?? 0;
if (aliasMax === 0) {
  console.log('SKIP topic alias: broker advertises TopicAliasMaximum 0');
} else {
  const long = `${base}/alias/site-01/building-a/floor-3/sensor-temperature`;
  await sub.subscribeAsync(long, { qos: 1 });
  incoming = nextMessage(sub);
  await aliasClient.publishAsync(long, 'first', { qos: 1, properties: { topicAlias: 1 } });
  assert.equal((await incoming).topic, long);
  incoming = nextMessage(sub);
  await aliasClient.publishAsync('', 'second', { qos: 1, properties: { topicAlias: 1 } });
  assert.equal((await incoming).topic, long, 'broker did not resolve the alias');
  console.log(`PASS topic alias (broker allows ${aliasMax})`);
}

// 4. No local: a client must not receive its own publish
await pub.subscribeAsync(`${base}/nolocal`, { qos: 1, nl: true });
incoming = nextMessage(pub, 2000);
await pub.publishAsync(`${base}/nolocal`, 'echo?', { qos: 1 });
await assert.rejects(incoming, /timeout/, 'received own message despite no-local');
console.log('PASS no local');

await Promise.all([sub, pub, late, aliasClient].map((c) => c.endAsync()));

Lancez-le avec node mqtt5.test.mjs. Quelques détails à connaître :

  • Les user properties sont un simple objet dans MQTT.js. Si une clé apparaît plusieurs fois sur le fil, sa valeur devient un tableau — MQTT 5 autorise les clés en double et leur ordre doit être conservé.
  • L'ordre compte dans le test d'expiry. Attachez l'écouteur de messages avant de vous abonner, car un message retenu peut arriver avant que subscribeAsync ne soit résolu.
  • Topic alias hors limites. MQTT.js refuse d'envoyer un alias supérieur au TopicAliasMaximum du broker et émet une erreur. Un broker qui en reçoit un malgré tout déconnecte avec 0x94 Topic Alias invalid. Plus de détails dans le test des topic aliases.
  • No local est une option d'abonnement, définie par abonnement. Les autres options (retain as published, retain handling) sont expliquées dans options d'abonnement MQTT.

paho-mqtt : session expiry et expiration des messages en file

Le test MQTT 5 le plus utile est aussi celui qu'on saute le plus souvent : que deviennent les messages pendant qu'un appareil est hors ligne ? Le script crée une session persistante, se déconnecte, publie deux messages (l'un avec un expiry de 60 s, l'autre de 2 s), attend, puis se reconnecte.

session_expiry_test.py
import time, uuid
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties

HOST, PORT = "broker.connectmq.com", 1883
TOPIC = f"testmqtt/mqtt5/{uuid.uuid4().hex[:8]}/queued"
CLIENT_ID = f"mqtt5-session-{uuid.uuid4().hex[:8]}"
received = []

def on_connect(client, userdata, flags, reason_code, properties):
    print("connected:", reason_code, "| session present:", flags.session_present)

def on_message(client, userdata, msg):
    expiry = getattr(msg.properties, "MessageExpiryInterval", None)
    user = getattr(msg.properties, "UserProperty", None)
    print(f"got {msg.payload.decode()!r}  expiry left: {expiry}  user properties: {user}")
    received.append(msg.payload.decode())

def session_client():
    c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID,
                    protocol=mqtt.MQTTv5)
    c.on_connect, c.on_message = on_connect, on_message
    props = Properties(PacketTypes.CONNECT)
    props.SessionExpiryInterval = 120   # keep the session 2 minutes after disconnect
    c.connect(HOST, PORT, clean_start=False, properties=props)
    c.loop_start()
    time.sleep(1)
    return c

# 1. Create the session and subscription, then go offline
s = session_client()
s.subscribe(TOPIC, qos=1)
time.sleep(1)
s.disconnect(); s.loop_stop()

# 2. Publish while the subscriber is offline
pub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
pub.connect(HOST, PORT); pub.loop_start()
for i, expiry in enumerate((60, 2)):
    props = Properties(PacketTypes.PUBLISH)
    props.MessageExpiryInterval = expiry
    props.UserProperty = ("test", f"msg-{i}")
    pub.publish(TOPIC, f"msg-{i}", qos=1, properties=props).wait_for_publish()
pub.disconnect(); pub.loop_stop()

time.sleep(5)  # longer than the 2 s expiry

# 3. Reconnect: msg-0 arrives with less than 60 s left, msg-1 has expired
s = session_client()
time.sleep(3)
s.disconnect(); s.loop_stop()
assert received == ["msg-0"], received
print("PASS session kept, unexpired message delivered, expired message dropped")

Sortie attendue : la seconde connexion affiche session present: True, et msg-0 arrive avec un expiry d'environ 54–55 secondes. Le broker doit transmettre la durée de vie restante, pas la valeur d'origine — si vous voyez 60, il ne la décrémente pas et les consommateurs ne peuvent pas savoir à quel point le message est périmé. Si session present vaut False, le broker n'a pas conservé la session : vérifiez son session expiry maximal et si le client ID a changé. Le test du session expiry approfondit les cas limites comme l'expiry à la déconnexion.

Request/response avec response topic et correlation data

MQTT 5 standardise le request/response : le demandeur définit un response topic et des correlation data ; le répondeur publie sa réponse sur ce topic en recopiant les correlation data. Le test 1 ci-dessus vérifie déjà que le broker transmet ces deux propriétés. Pour tester un vrai répondeur, abonnez-vous au response topic, envoyez une requête et vérifiez que la réponse porte les mêmes correlation data dans le délai imparti. Le modèle et un exemple complet se trouvent dans le test request/response MQTT.

Tester des flottes mixtes MQTT 3.1.1 et MQTT 5

La plupart des flottes restent mixtes pendant des années. Ajoutez une exécution du test des propriétés avec l'abonné connecté en 3.1.1 (protocolVersion: 4 dans MQTT.js, protocol=mqtt.MQTTv311 dans paho) :

  • Le message doit toujours arriver.
  • packet.properties est absent : les clients 3.1.1 ne voient jamais les user properties, le content type ni les correlation data.
  • Le message expiry fonctionne toujours, car c'est le broker qui l'applique.

Si votre backend s'appuie sur les user properties pour le routage ou le tracing, cette exécution montre quels appareils devront se rabattre sur le payload. Voir MQTT 5 vs 3.1.1 pour la liste complète des différences.

Suivre les tests MQTT 5 dans le navigateur

Les fonctionnalités MQTT 5 ne sont fiables qu'à hauteur de l'implémentation la plus faible entre votre publisher, le broker et votre abonné. Quelques centaines de lignes de tests — aller-retour des propriétés, expiry, alias, sessions — révèlent les écarts tôt. Gardez-les en CI et relancez-les après chaque mise à jour du broker ou de la bibliothèque.

Questions fréquentes

Les propriétés MQTT 5 fonctionnent-elles si l'abonné utilise MQTT 3.1.1 ?

Le message est livré, mais un abonné 3.1.1 ne peut pas recevoir de propriétés : user properties, content type, response topic et correlation data sont supprimés pour cette connexion. Le message expiry continue de s'appliquer côté broker.

Pourquoi mon message retenu n'expire-t-il pas ?

Vérifiez que vous avez publié en MQTT 5 (les propriétés sont ignorées sur une connexion 3.1.1), que l'expiry interval est défini sur le PUBLISH et non sur le CONNECT, et que votre broker gère l'expiration des messages retenus. Testez avec un intervalle court et un nouvel abonné.

Dois-je gérer les topic aliases moi-même ?

Pas forcément. MQTT.js peut attribuer et réutiliser des alias automatiquement (autoAssignTopicAlias, autoUseTopicAlias) et beaucoup de bibliothèques résolvent les alias entrants pour vous. Ne les gérez à la main que si vous avez besoin d'un contrôle précis de la bande passante.