Testar clientes MQTT em Go funciona melhor com o go test puro: um pequeno helper que conecta um cliente, um channel com buffer que recebe as mensagens e um select com timeout que transforma "a mensagem nunca chegou" em uma falha limpa. Este guia mostra esse padrão para paho.mqtt.golang (MQTT 3.1.1) e autopaho (MQTT 5), além de testes em tabela e fakes para o código que não deveria precisar de broker.
Dois tipos de teste
| Tipo de teste | Fala com um broker? | Objetivo |
|---|---|---|
| Teste unitário | Não — um publisher falso | A lógica dos seus handlers, a codificação do payload, a montagem de topics |
| Teste de integração | Sim — broker real | Opções de conexão, TLS, autenticação, QoS, mensagens retidas, ACLs |
Mantenha a maior parte da lógica testável sem MQTT dependendo de uma interface pequena em vez do cliente concreto, e reserve o broker para o que só um broker consegue provar. O resto do guia trata de testes de integração; os fakes vêm no final.
Um helper de teste para paho.mqtt.golang
Se você nunca usou a biblioteca, MQTT em Go com Paho cobre o básico. Nos testes, encapsule a conexão em um helper que chama t.Helper() e registra a limpeza, para que cada teste tenha três linhas.
//go:build integration
package telemetry
import (
"crypto/rand"
"encoding/hex"
"os"
"testing"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
func brokerURL() string {
if u := os.Getenv("MQTT_URL"); u != "" {
return u
}
return "tcp://broker.connectmq.com:1883"
}
func randomID(t *testing.T) string {
t.Helper()
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
t.Fatal(err)
}
return hex.EncodeToString(b)
}
// newClient connects a client and disconnects it when the test ends.
func newClient(t *testing.T) mqtt.Client {
t.Helper()
opts := mqtt.NewClientOptions().
AddBroker(brokerURL()).
SetClientID("gotest-" + randomID(t)).
SetUsername(os.Getenv("MQTT_USER")).
SetPassword(os.Getenv("MQTT_PASS")).
SetCleanSession(true).
SetConnectTimeout(10 * time.Second)
c := mqtt.NewClient(opts)
tok := c.Connect()
if !tok.WaitTimeout(15 * time.Second) {
t.Fatal("connect: timed out")
}
if err := tok.Error(); err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { c.Disconnect(250) })
return c
}
// subscribe returns a buffered channel that receives every message on filter.
func subscribe(t *testing.T, c mqtt.Client, filter string, qos byte) <-chan mqtt.Message {
t.Helper()
ch := make(chan mqtt.Message, 100)
tok := c.Subscribe(filter, qos, func(_ mqtt.Client, m mqtt.Message) { ch <- m })
if !tok.WaitTimeout(5*time.Second) || tok.Error() != nil {
t.Fatalf("subscribe %s: %v", filter, tok.Error())
}
return ch
}
// expectMessage fails the test unless a message with payload want arrives in time.
func expectMessage(t *testing.T, ch <-chan mqtt.Message, want string, timeout time.Duration) mqtt.Message {
t.Helper()
select {
case m := <-ch:
if got := string(m.Payload()); got != want {
t.Fatalf("topic %s: got payload %q, want %q", m.Topic(), got, want)
}
return m
case <-time.After(timeout):
t.Fatalf("no message within %s", timeout)
return nil
}
}Três detalhes evitam os travamentos mais comuns:
WaitTimeoutem vez deWait.Wait()bloqueia para sempre se o broker não responder; ogo testsó mata um teste travado depois do timeout padrão de 10 minutos.- Um channel com buffer. Por padrão o paho entrega mensagens em ordem, então um handler bloqueado em um channel cheio trava o cliente inteiro — inclusive as confirmações que seu publish está esperando.
- Esperar o SUBACK.
Subscriberetorna um token; espere por ele antes de publicar, ou a primeira mensagem pode passar à frente da inscrição.
Um teste de ida e volta orientado a tabela
Com os helpers prontos, um teste que verifica a entrega em cada nível de QoS fica curto e legível:
//go:build integration
package telemetry
import (
"testing"
"time"
)
func TestRoundTrip(t *testing.T) {
tests := []struct {
name string
qos byte
payload string
}{
{"qos0 small", 0, "21.5"},
{"qos1 json", 1, `{"temp":21.5,"unit":"C"}`},
{"qos2 text", 2, "door=open"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
c := newClient(t)
topic := "testmqtt/go/" + randomID(t) + "/temperature"
msgs := subscribe(t, c, topic, tc.qos)
tok := c.Publish(topic, tc.qos, false, tc.payload)
if !tok.WaitTimeout(5*time.Second) || tok.Error() != nil {
t.Fatalf("publish: %v", tok.Error())
}
m := expectMessage(t, msgs, tc.payload, 5*time.Second)
if m.Qos() != tc.qos {
t.Errorf("delivered at QoS %d, want %d", m.Qos(), tc.qos)
}
})
}
}Cada subteste recebe um cliente e um topic novos, então t.Parallel() é seguro. Note que o QoS entregue é o mínimo entre o QoS de publicação e o da inscrição — uma divergência aqui geralmente significa que o broker rebaixou a inscrição. Mais detalhes em testar a entrega por QoS.
Rode a suíte com o detector de race e sem cache de testes, porque um resultado em cache não diz nada sobre o broker de hoje:
export MQTT_URL=tcp://broker.connectmq.com:1883
go test -tags integration -race -count=1 -timeout 2m ./...Verificando silêncio: testes de ACL e de filtros
Alguns testes precisam provar que uma mensagem não chega — um wildcard que não deveria casar ou um topic que o cliente não pode ler. Adicione um segundo helper:
func expectNoMessage(t *testing.T, ch <-chan mqtt.Message, wait time.Duration) {
t.Helper()
select {
case m := <-ch:
t.Fatalf("unexpected message on %s: %q", m.Topic(), m.Payload())
case <-time.After(wait):
}
}Escolha wait com base na latência medida: se um round trip positivo normalmente leva 50 ms, dois segundos de silêncio são uma evidência forte. Verifique antes a lógica do seu filtro com o topic matcher para saber que resultado esperar.
O mesmo padrão com autopaho (MQTT 5)
O autopaho, de github.com/eclipse/paho.golang, cuida da reconexão e fala MQTT 5. As mensagens chegam pelos callbacks OnPublishReceived; encaminhe-as para um channel exatamente como antes.
//go:build integration
package telemetry
import (
"context"
"net/url"
"testing"
"time"
"github.com/eclipse/paho.golang/autopaho"
"github.com/eclipse/paho.golang/paho"
)
func TestAutopahoRoundTrip(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
u, _ := url.Parse("mqtt://broker.connectmq.com:1883")
msgs := make(chan *paho.Publish, 10)
topic := "testmqtt/go/" + randomID(t) + "/v5"
cm, err := autopaho.NewConnection(ctx, autopaho.ClientConfig{
ServerUrls: []*url.URL{u},
KeepAlive: 20,
CleanStartOnInitialConnection: true,
ClientConfig: paho.ClientConfig{
ClientID: "gotest-v5-" + randomID(t),
OnPublishReceived: []func(paho.PublishReceived) (bool, error){
func(pr paho.PublishReceived) (bool, error) {
msgs <- pr.Packet
return true, nil
},
},
},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cm.Disconnect(context.Background()) })
if err := cm.AwaitConnection(ctx); err != nil {
t.Fatalf("connect: %v", err)
}
if _, err := cm.Subscribe(ctx, &paho.Subscribe{
Subscriptions: []paho.SubscribeOptions{{Topic: topic, QoS: 1}},
}); err != nil {
t.Fatalf("subscribe: %v", err)
}
if _, err := cm.Publish(ctx, &paho.Publish{
Topic: topic, QoS: 1, Payload: []byte("hello v5"),
Properties: &paho.PublishProperties{
User: paho.UserProperties{{Key: "test", Value: t.Name()}},
},
}); err != nil {
t.Fatalf("publish: %v", err)
}
select {
case p := <-msgs:
if string(p.Payload) != "hello v5" {
t.Fatalf("got %q", p.Payload)
}
if p.Properties.User.Get("test") != t.Name() {
t.Errorf("user property lost")
}
case <-ctx.Done():
t.Fatal("no message before deadline")
}
}Verificar uma user property é um jeito barato de checar se o MQTT 5 é negociado de ponta a ponta — um broker ou proxy que volte silenciosamente para 3.1.1 a descartaria. A API do autopaho mudou entre versões menores, então fixe a versão do módulo no go.mod e leia as notas de release ao atualizar.
Testes unitários sem broker
Código que só publica raramente precisa de um broker real em testes unitários. Dependa de uma interface e troque por um fake:
type Publisher interface {
Publish(topic string, qos byte, retained bool, payload interface{}) mqtt.Token
}
// In the test file:
type sentMsg struct {
topic string
payload interface{}
}
type fakePublisher struct{ sent []sentMsg }
func (f *fakePublisher) Publish(topic string, _ byte, _ bool, p interface{}) mqtt.Token {
f.sent = append(f.sent, sentMsg{topic, p})
return &mqtt.DummyToken{}
}mqtt.Client já satisfaz essa interface, então o código de produção passa o cliente real e os testes passam o fake. mqtt.DummyToken é um token pronto que conclui na hora, sem erro. Para comportamentos que dependem da semântica do broker — mensagens retidas, correspondência de wildcards, estado de sessão — um broker embutido ou um broker mock é a ferramenta melhor; veja brokers mock para testes unitários.
Para onde os testes de integração devem apontar
Localmente, um container Mosquitto ou o endpoint compartilhado broker.connectmq.com resolvem para execuções rápidas. Na CI você quer um broker parecido com a produção: a mesma porta TLS (ssl://...:8883 no paho, mqtts:// no autopaho), credenciais reais e as ACLs às quais seus dispositivos estão sujeitos. Testes MQTT no GitHub Actions mostra como injetá-las como secrets.
Quando um teste falha e você não sabe por quê, rode-o com -run e -v e acompanhe ao mesmo tempo testmqtt/go/# no cliente MQTT online. Se a mensagem aparece lá mas não no teste, o problema está na sua inscrição ou no handler; se nunca aparece, olhe o lado do publish — o guia de erros do Paho em Go lista as causas mais comuns.
Veja o tráfego do go test no navegador
Go torna os testes MQTT agradáveis quando a assincronia está sob controle: helpers que falham rápido com t.Fatalf, channels com buffer, um select com timeout para cada expectativa e um client ID e um topic únicos por subteste. Mantenha a lógica sem broker atrás de uma interface, rode os round trips reais atrás de uma build tag e a mesma suíte vai cobrir seu notebook, a CI e um smoke test pós-deploy.
Perguntas frequentes
Por que meu teste MQTT em Go fica travado para sempre?
Geralmente um token é aguardado com Wait() e o broker nunca responde, ou um handler de mensagens bloqueia em um channel sem buffer e trava o roteador do cliente. Use WaitTimeout, dê buffer aos channels de mensagens e sempre faça select com timeout ao lê-los.
Testes MQTT em Go devem usar t.Parallel?
Sim, desde que cada teste use seu próprio client ID e prefixo de topic. Testes de integração passam a maior parte do tempo esperando a rede, então rodá-los em paralelo encurta bastante a suíte.
Como pulo os testes de integração MQTT nas execuções unitárias?
Coloque-os atrás de uma build tag como //go:build integration e rode-os com go test -tags integration, ou chame t.Skip quando testing.Short() for true ou quando a variável de ambiente MQTT_URL não estiver definida.


