Testes de brokers

Testando recursos do MQTT 5: user properties, expiry e topic aliases

Testes práticos de recursos do MQTT 5 com MQTT.js e paho-mqtt: user properties, expiração de mensagens e sessões, topic aliases, request/response e no-local.

Atualizado · 10 min de leitura

Testando recursos do MQTT 5: user properties, expiry e topic aliases

O MQTT 5 trouxe recursos que mudam o comportamento das aplicações — metadados em user properties, mensagens que expiram, pacotes menores com topic aliases, sessões com tempo de vida. Cada um depende de a biblioteca cliente e o broker implementarem corretamente, então teste os recursos do MQTT 5 de ponta a ponta antes de depender deles.

Este artigo traz testes pequenos e executáveis com MQTT.js 5 (Node.js) e paho-mqtt 2.x (Python). Cada teste publica, observa o que chega e faz uma asserção — então pode rodar no CI tão bem quanto no seu notebook.

O que testar e o que significa "passar"

RecursoTesteCondição de sucesso
User propertiesPublicar com pares chave/valorO assinante recebe os mesmos pares
Content type, correlation dataPublicar com ambos definidosO assinante recebe ambos sem alteração
Message expiry (retida)Mensagem retida com expiry de 3 s, assinar após 5 sNada é entregue
Message expiry (enfileirada)Enfileirar mensagens para uma sessão offlineA mensagem não expirada chega com intervalo reduzido; a expirada é descartada
Session expiryReconectar com clean_start=Falsesession present é true e as mensagens enfileiradas chegam
Topic aliasesRegistrar um alias e publicar com topic vazioO assinante vê o nome completo do topic
No localAssinar e publicar na mesma conexão com nlO cliente não recebe a própria mensagem

Primeiro, confirme que o broker aceita conexões MQTT 5 e confira os limites do CONNACK (topic alias maximum, maximum QoS) com a sonda de recursos do broker. Um teste que falha porque o broker anuncia TopicAliasMaximum 0 é um achado de configuração, não um bug.

Preparação

Instalar as duas bibliotecas
npm install mqtt@5
pip install "paho-mqtt>=2.0"

Todos os exemplos usam broker.connectmq.com:1883 e um prefixo de topic aleatório em testmqtt/mqtt5/, para que execuções paralelas não vejam as mensagens umas das outras. Adicione username e password ($MQTT_USER / $MQTT_PASS) se o seu broker exigir.

MQTT.js: propriedades, expiry, aliases e no-local

O script abaixo roda quatro testes em sequência e sai com código diferente de zero na primeira falha. nextMessage espera uma mensagem ou estoura o timeout, o que torna "nada deve chegar" tão fácil de verificar quanto "isto deve chegar".

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()));

Rode com node mqtt5.test.mjs. Alguns detalhes que vale conhecer:

  • User properties no MQTT.js são um objeto simples. Se uma chave aparece várias vezes no pacote, o valor vira um array — o MQTT 5 permite chaves duplicadas e a ordem delas deve ser preservada.
  • A ordem importa no teste de expiry. Registre o listener de mensagens antes de assinar, porque uma mensagem retida pode chegar antes de subscribeAsync resolver.
  • Topic alias fora do limite. O MQTT.js se recusa a enviar um alias acima do TopicAliasMaximum do broker e emite um erro. Um broker que recebe um mesmo assim desconecta com 0x94 Topic Alias invalid. Mais no teste de topic alias.
  • No local é uma opção de assinatura, definida por assinatura. As outras opções (retain as published, retain handling) são explicadas em opções de assinatura MQTT.

paho-mqtt: session expiry e expiração de mensagens enfileiradas

O teste de MQTT 5 mais valioso é também o mais esquecido: o que acontece com as mensagens enquanto um dispositivo está offline. O script cria uma sessão persistente, desconecta, publica duas mensagens (uma com expiry de 60 s e outra de 2 s), espera e reconecta.

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")

Saída esperada: a segunda conexão imprime session present: True, e msg-0 chega com um expiry de uns 54–55 segundos. O broker precisa repassar o tempo de vida restante, não o valor original — se você vir 60, o broker não está decrementando, e os consumidores não conseguem saber quão antiga é a mensagem. Se session present for False, o broker não manteve a sessão: verifique o session expiry máximo configurado e se o client ID mudou. O teste de session expiry aprofunda casos de borda como expiry na desconexão.

Request/response com response topic e correlation data

O MQTT 5 padroniza o request/response: quem pede define um response topic e correlation data; quem responde publica a resposta nesse topic e copia a correlation data. O teste 1 acima já verifica se o broker repassa as duas propriedades. Para testar um responder real, assine o response topic, envie uma requisição e verifique se a resposta traz a mesma correlation data dentro do seu timeout. O padrão e um exemplo completo estão no teste de request/response MQTT.

Testando frotas mistas de MQTT 3.1.1 e MQTT 5

A maioria das frotas fica mista por anos. Adicione mais uma execução do teste de propriedades com o assinante conectado como 3.1.1 (protocolVersion: 4 no MQTT.js, protocol=mqtt.MQTTv311 no paho):

  • A mensagem ainda precisa chegar.
  • packet.properties não existe: clientes 3.1.1 nunca veem user properties, content type ou correlation data.
  • O message expiry continua funcionando, porque é o broker que o aplica.

Se o seu backend depende de user properties para roteamento ou tracing, essa execução mostra quais dispositivos vão precisar recorrer ao payload. Veja MQTT 5 vs 3.1.1 para a lista completa de diferenças.

Veja os testes de MQTT 5 no navegador

Os recursos do MQTT 5 são tão confiáveis quanto a implementação mais fraca entre seu publisher, o broker e seu assinante. Algumas centenas de linhas de testes — ida e volta de propriedades, expiry, aliases, sessões — pegam as diferenças cedo. Mantenha-os no CI e rode de novo após cada atualização do broker ou da biblioteca.

Perguntas frequentes

As propriedades do MQTT 5 funcionam se o assinante usar MQTT 3.1.1?

A mensagem é entregue, mas um assinante 3.1.1 não consegue receber propriedades: user properties, content type, response topic e correlation data são descartados para essa conexão. O message expiry continua valendo no broker.

Por que minha mensagem retida não expira?

Confirme que você publicou com MQTT 5 (propriedades são ignoradas em conexões 3.1.1), que o expiry interval está no PUBLISH e não no CONNECT, e que o seu broker implementa expiração de mensagens retidas. Teste com um intervalo curto e um assinante novo.

Preciso gerenciar topic aliases por conta própria?

Não necessariamente. O MQTT.js pode atribuir e reutilizar aliases automaticamente (autoAssignTopicAlias, autoUseTopicAlias), e muitas bibliotecas resolvem os aliases recebidos para você. Gerencie manualmente só quando precisar de controle exato de banda.