Testes de brokers

Como testar clientes MQTT em Go

Teste clientes MQTT em Go com go test: helpers para paho.mqtt.golang e autopaho, channels para verificar mensagens, testes em tabela de QoS e fakes unitários.

Atualizado · 9 min de leitura

Como testar clientes MQTT em Go

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 testeFala com um broker?Objetivo
Teste unitárioNão — um publisher falsoA lógica dos seus handlers, a codificação do payload, a montagem de topics
Teste de integraçãoSim — broker realOpçõ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.

mqtt_helpers_test.go
//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:

  • WaitTimeout em vez de Wait. Wait() bloqueia para sempre se o broker não responder; o go test só 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. Subscribe retorna 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:

roundtrip_test.go
//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:

Run integration tests
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:

expect_none_test.go
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.

autopaho_test.go
//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:

publisher.go and publisher_test.go
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.