MQTT-Clients in Go testen klappt am besten mit schlichtem go test: ein kleiner Helper, der einen Client verbindet, ein gepufferter Channel, der Nachrichten empfängt, und ein select mit Timeout, das aus "die Nachricht kam nie an" einen sauberen Testfehler macht. Dieser Guide zeigt das Muster für paho.mqtt.golang (MQTT 3.1.1) und autopaho (MQTT 5), dazu Tabellentests und Fakes für Code, der gar keinen Broker brauchen sollte.
Zwei Arten von Tests
| Testart | Spricht mit einem Broker? | Zweck |
|---|---|---|
| Unit-Test | Nein — ein Fake-Publisher | Handler-Logik, Payload-Kodierung, Topic-Aufbau |
| Integrationstest | Ja — echter Broker | Verbindungsoptionen, TLS, Auth, QoS, Retained Messages, ACLs |
Halte den Großteil der Logik ohne MQTT testbar, indem du von einem kleinen Interface statt vom konkreten Client abhängst, und nutze den Broker nur für das, was nur ein Broker beweisen kann. Der Rest dieses Guides behandelt Integrationstests; Fakes kommen am Ende.
Ein Test-Helper für paho.mqtt.golang
Wenn du die Bibliothek noch nicht kennst, erklärt MQTT in Go mit Paho die Grundlagen. Für Tests kapselst du den Verbindungsaufbau in einem Helper, der t.Helper() aufruft und das Aufräumen registriert — so ist jeder Test drei Zeilen lang.
//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
}
}Drei Details verhindern die häufigsten Hänger:
WaitTimeoutstattWait.Wait()blockiert für immer, wenn der Broker nie antwortet;go testbeendet einen hängenden Test erst nach dem Standard-Timeout von 10 Minuten.- Ein gepufferter Channel. paho liefert Nachrichten standardmäßig in Reihenfolge aus; ein Handler, der auf einem vollen Channel blockiert, legt also den ganzen Client lahm — einschließlich der Bestätigungen, auf die dein Publish wartet.
- Auf SUBACK warten.
Subscribegibt ein Token zurück; warte darauf, bevor du publizierst, sonst kann die erste Nachricht dem Abo zuvorkommen.
Ein tabellengetriebener Round-Trip-Test
Mit den Helpern ist ein Test, der die Zustellung auf jedem QoS-Level prüft, kurz und lesbar:
//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)
}
})
}
}Jeder Subtest bekommt einen frischen Client und ein frisches Topic, t.Parallel() ist also sicher. Beachte: Der zugestellte QoS ist das Minimum aus Publish- und Abo-QoS — eine Abweichung bedeutet meist, dass der Broker das Abo herabgestuft hat. Mehr dazu unter QoS-Zustellung testen.
Führe die Suite mit Race-Detector und ohne Test-Cache aus, denn ein gecachtes Ergebnis sagt nichts über den heutigen Broker:
export MQTT_URL=tcp://broker.connectmq.com:1883
go test -tags integration -race -count=1 -timeout 2m ./...Stille prüfen: ACL- und Filtertests
Manche Tests müssen beweisen, dass eine Nachricht nicht ankommt — ein Wildcard, der nicht matchen soll, oder ein Topic, das der Client nicht lesen darf. Ergänze einen zweiten 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):
}
}Wähle wait anhand gemessener Latenz: Dauert ein positiver Round Trip normalerweise 50 ms, sind zwei Sekunden Stille ein starkes Indiz. Prüfe deine Filterlogik vorher mit dem Topic Matcher, damit du weißt, welches Ergebnis zu erwarten ist.
Dasselbe Muster mit autopaho (MQTT 5)
autopaho aus github.com/eclipse/paho.golang kümmert sich um Reconnects und spricht MQTT 5. Nachrichten kommen über OnPublishReceived-Callbacks an; leite sie wie zuvor in einen Channel weiter.
//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")
}
}Eine Assertion auf eine User Property ist ein günstiger Weg zu prüfen, dass MQTT 5 durchgängig ausgehandelt wird — ein Broker oder Proxy, der still auf 3.1.1 zurückfällt, würde sie verwerfen. Die API von autopaho hat sich zwischen Minor-Versionen geändert; pinne die Modulversion in go.mod und lies beim Upgrade die Release Notes.
Unit-Tests ohne Broker
Code, der nur publiziert, braucht in Unit-Tests selten einen echten Broker. Hänge von einem Interface ab und setze einen Fake ein:
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 erfüllt dieses Interface bereits, also übergibt der Produktionscode den echten Client und die Tests den Fake. mqtt.DummyToken ist ein fertiges Token, das sofort ohne Fehler abschließt. Für Verhalten, das von Broker-Semantik abhängt — Retained Messages, Wildcard-Matching, Session-Zustand — ist ein eingebetteter Broker oder ein Mock-Broker das bessere Werkzeug; siehe Mock-Broker für Unit-Tests.
Wohin die Integrationstests zeigen sollten
Lokal reichen ein Mosquitto-Container oder der geteilte Endpoint broker.connectmq.com für schnelle Läufe. In der CI willst du einen Broker, der der Produktion entspricht: derselbe TLS-Port (ssl://...:8883 für paho, mqtts:// für autopaho), echte Zugangsdaten und die ACLs, denen deine Geräte unterliegen. MQTT-Tests in GitHub Actions zeigt, wie du sie als Secrets einbindest.
Wenn ein Test fehlschlägt und du nicht weißt warum, starte ihn mit -run und -v und beobachte gleichzeitig testmqtt/go/# im Online-MQTT-Client. Taucht die Nachricht dort auf, aber nicht im Test, liegt das Problem bei Abo oder Handler; taucht sie nie auf, schau auf die Publish-Seite — der Fehler-Guide für Paho in Go listet die üblichen Ursachen.
go-test-Traffic im Browser ansehen
Go macht MQTT-Tests angenehm, sobald die Asynchronität eingehegt ist: Helper, die mit t.Fatalf schnell scheitern, gepufferte Channels, ein select mit Timeout für jede Erwartung und eine eindeutige Client-ID samt Topic pro Subtest. Halte brokerfreie Logik hinter einem Interface, führe echte Round Trips hinter einem Build-Tag aus, und dieselbe Suite deckt Laptop, CI und einen Smoke-Test nach dem Deployment ab.
Häufig gestellte Fragen
Warum hängt mein Go-MQTT-Test für immer?
Meist wird auf ein Token mit Wait() gewartet und der Broker antwortet nie, oder ein Message-Handler blockiert auf einem ungepufferten Channel und legt den Router des Clients lahm. Nutze WaitTimeout, gib Nachrichten-Channels einen Puffer und lies sie immer per select mit Timeout.
Sollten Go-MQTT-Tests t.Parallel verwenden?
Ja, solange jeder Test eine eigene Client-ID und ein eigenes Topic-Präfix nutzt. Integrationstests warten die meiste Zeit auf das Netzwerk, parallel ausgeführt wird die Suite deutlich kürzer.
Wie überspringe ich MQTT-Integrationstests bei Unit-Test-Läufen?
Lege sie hinter einen Build-Tag wie //go:build integration und starte sie mit go test -tags integration, oder rufe t.Skip auf, wenn testing.Short() true ist oder die Umgebungsvariable MQTT_URL fehlt.


