Tester des clients MQTT en Go fonctionne le mieux avec un simple go test : un petit helper qui connecte un client, un channel avec buffer qui reçoit les messages et un select avec timeout qui transforme « le message n'est jamais arrivé » en un échec propre. Ce guide présente ce pattern pour paho.mqtt.golang (MQTT 3.1.1) et autopaho (MQTT 5), ainsi que des tests en table et des fakes pour le code qui ne devrait pas avoir besoin de broker.
Deux types de tests
| Type de test | Parle à un broker ? | Objectif |
|---|---|---|
| Test unitaire | Non — un faux publisher | Logique des handlers, encodage du payload, construction des topics |
| Test d'intégration | Oui — vrai broker | Options de connexion, TLS, authentification, QoS, messages retenus, ACL |
Gardez l'essentiel de la logique testable sans MQTT en dépendant d'une petite interface plutôt que du client concret, et réservez le broker à ce que seul un broker peut prouver. La suite du guide traite des tests d'intégration ; les fakes arrivent à la fin.
Un helper de test pour paho.mqtt.golang
Si vous n'avez jamais utilisé la bibliothèque, MQTT en Go avec Paho couvre les bases. Pour les tests, encapsulez la connexion dans un helper qui appelle t.Helper() et enregistre le nettoyage : chaque test tient alors en trois lignes.
//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
}
}Trois détails évitent les blocages les plus fréquents :
WaitTimeoutau lieu deWait.Wait()bloque indéfiniment si le broker ne répond jamais ;go testne tue un test bloqué qu'après le timeout par défaut de 10 minutes.- Un channel avec buffer. Par défaut, paho livre les messages dans l'ordre : un handler bloqué sur un channel plein fige donc tout le client — y compris les acquittements qu'attend votre publish.
- Attendre le SUBACK.
Subscriberenvoie un token ; attendez-le avant de publier, sinon le premier message peut devancer l'abonnement.
Un test aller-retour piloté par table
Avec ces helpers, un test qui vérifie la livraison à chaque niveau de QoS reste court et lisible :
//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)
}
})
}
}Chaque sous-test reçoit un client et un topic neufs, donc t.Parallel() est sûr. Notez que le QoS livré est le minimum entre le QoS de publication et celui de l'abonnement — un écart ici signifie souvent que le broker a rétrogradé l'abonnement. Plus de détails dans tester la livraison par QoS.
Exécutez la suite avec le détecteur de race et sans cache de tests, car un résultat en cache ne dit rien du broker d'aujourd'hui :
export MQTT_URL=tcp://broker.connectmq.com:1883
go test -tags integration -race -count=1 -timeout 2m ./...Vérifier le silence : tests d'ACL et de filtres
Certains tests doivent prouver qu'un message n'arrive pas — un wildcard qui ne devrait pas correspondre ou un topic que le client n'a pas le droit de lire. Ajoutez un second 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):
}
}Choisissez wait à partir de la latence mesurée : si un aller-retour positif prend habituellement 50 ms, deux secondes de silence sont une preuve solide. Vérifiez d'abord la logique de votre filtre avec le topic matcher pour savoir quel résultat attendre.
Le même pattern avec autopaho (MQTT 5)
autopaho, issu de github.com/eclipse/paho.golang, gère la reconnexion et parle MQTT 5. Les messages arrivent via les callbacks OnPublishReceived ; transférez-les dans un channel comme précédemment.
//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")
}
}Vérifier une user property est un moyen peu coûteux de contrôler que MQTT 5 est négocié de bout en bout — un broker ou un proxy qui repasse silencieusement en 3.1.1 la supprimerait. L'API d'autopaho a changé entre versions mineures : épinglez la version du module dans go.mod et lisez les notes de version lors des mises à jour.
Tests unitaires sans broker
Le code qui se contente de publier a rarement besoin d'un vrai broker en test unitaire. Dépendez d'une interface et remplacez-la par un 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 satisfait déjà cette interface : le code de production passe le vrai client et les tests passent le fake. mqtt.DummyToken est un token prêt à l'emploi qui se termine immédiatement sans erreur. Pour les comportements qui dépendent de la sémantique du broker — messages retenus, correspondance des wildcards, état de session — un broker embarqué ou un broker mock est un meilleur outil ; voir brokers mock pour les tests unitaires.
Vers quel broker pointer les tests d'intégration
En local, un conteneur Mosquitto ou l'endpoint partagé broker.connectmq.com suffisent pour des exécutions rapides. En CI, il vous faut un broker proche de la production : le même port TLS (ssl://...:8883 pour paho, mqtts:// pour autopaho), de vrais identifiants et les ACL auxquelles vos appareils sont soumis. Les tests MQTT dans GitHub Actions montrent comment les injecter sous forme de secrets.
Quand un test échoue sans raison apparente, lancez-le avec -run et -v et observez en parallèle testmqtt/go/# dans le client MQTT en ligne. Si le message y apparaît mais pas dans votre test, le problème vient de votre abonnement ou de votre handler ; s'il n'apparaît jamais, regardez côté publish — le guide des erreurs Paho en Go recense les causes habituelles.
Suivre le trafic de go test dans le navigateur
Go rend les tests MQTT agréables une fois l'asynchronisme maîtrisé : des helpers qui échouent vite avec t.Fatalf, des channels avec buffer, un select avec timeout pour chaque attente et un client ID et un topic uniques par sous-test. Gardez la logique sans broker derrière une interface, exécutez les vrais allers-retours derrière un build tag, et la même suite couvrira votre portable, la CI et un smoke test après déploiement.
Questions fréquentes
Pourquoi mon test MQTT en Go reste-t-il bloqué indéfiniment ?
En général, un token est attendu avec Wait() et le broker ne répond jamais, ou un handler de messages bloque sur un channel sans buffer et fige le routeur du client. Utilisez WaitTimeout, donnez un buffer aux channels de messages et lisez-les toujours via un select avec timeout.
Les tests MQTT en Go doivent-ils utiliser t.Parallel ?
Oui, à condition que chaque test utilise son propre client ID et son propre préfixe de topic. Les tests d'intégration passent l'essentiel de leur temps à attendre le réseau : les exécuter en parallèle raccourcit nettement la suite.
Comment ignorer les tests d'intégration MQTT lors des exécutions unitaires ?
Placez-les derrière un build tag comme //go:build integration et lancez-les avec go test -tags integration, ou appelez t.Skip quand testing.Short() vaut true ou quand la variable d'environnement MQTT_URL n'est pas définie.


