Ein MQTT-Integrationstest in Java verbindet einen echten Client mit einem echten Broker, abonniert, publiziert und prüft, was ankommt. Mit JUnit 5 ist nicht die API das Schwierige, sondern die asynchrone Zustellung: Nachrichten kommen auf einem anderen Thread an, etwas später oder gar nicht. Dieser Guide zeigt ein Test-Setup, das damit sauber umgeht — mit Eclipse Paho, dem HiveMQ MQTT Client, Awaitility und Testcontainers.
Abhängigkeiten
Die Beispiele nutzen Maven; dieselben Koordinaten funktionieren in Gradle. Die Versionen waren zum Zeitpunkt des Schreibens aktuell — neuere Patch-Releases sind in Ordnung.
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
<dependency>
<groupId>com.hivemq</groupId>
<artifactId>hivemq-mqtt-client</artifactId>
<version>1.3.3</version>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.3</version>
<scope>test</scope>
</dependency>
</dependencies>Du brauchst nur den MQTT-Client, den deine Anwendung tatsächlich verwendet. Wenn dir die Bibliotheken neu sind, starte mit MQTT in Java mit Paho oder dem HiveMQ-MQTT-Client-Guide.
Regeln für MQTT-Tests, die nicht flaky sind
| Regel | Warum |
|---|---|
| Eindeutige Client-ID pro Test | Zwei Clients mit derselben ID trennen sich gegenseitig ("session taken over") |
| Eindeutiges Topic-Präfix pro Test | Parallele Tests und alte Retained Messages landen nicht in deinen Assertions |
| Erst abonnieren, auf SUBACK warten, dann publizieren | Eine Nachricht, die vor dem aktiven Abo publiziert wird, wird schlicht nicht zugestellt |
Mit Timeout warten, nie mit Thread.sleep | Schnell, wenn es klappt, ein klarer Fehler, wenn nicht |
In @AfterEach trennen | Vergessene Verbindungen erschöpfen Broker-Limits und halten Sessions am Leben |
| Broker-Adresse aus der Konfiguration | Dieselben Tests laufen lokal gegen Testcontainers und in der CI gegen einen gehosteten Broker |
Der Client-ID-Generator zeigt Formate, die bei allen Brokern funktionieren; in Tests genügt ein Präfix mit zufälligem Suffix.
Ein Paho-Test mit Awaitility
Der synchrone MqttClient von Paho blockiert connect() und subscribe(), bis der Broker sie bestätigt hat — das macht das Test-Setup einfach. Die Zustellung passiert trotzdem auf Pahos Callback-Thread, also sammelst du Nachrichten in einer thread-sicheren Queue und lässt Awaitility sie abfragen.
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.eclipse.paho.client.mqttv3.*;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.junit.jupiter.api.*;
class PahoRoundTripTest {
static final String BROKER =
System.getenv().getOrDefault("MQTT_URL", "tcp://broker.connectmq.com:1883");
MqttClient client;
String topic;
@BeforeEach
void connect() throws MqttException {
String id = UUID.randomUUID().toString().substring(0, 8);
topic = "testmqtt/junit/" + id + "/temperature";
client = new MqttClient(BROKER, "junit-" + id, new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setCleanSession(true);
options.setConnectionTimeout(10);
String user = System.getenv("MQTT_USER");
if (user != null) {
options.setUserName(user);
options.setPassword(System.getenv("MQTT_PASS").toCharArray());
}
client.connect(options);
}
@AfterEach
void disconnect() throws MqttException {
if (client.isConnected()) client.disconnect();
client.close();
}
@Test
void publishedMessageIsReceived() throws MqttException {
var received = new ConcurrentLinkedQueue<String>();
client.subscribe(topic, 1, (t, msg) -> received.add(new String(msg.getPayload(), UTF_8)));
client.publish(topic, "21.5".getBytes(UTF_8), 1, false);
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertEquals("21.5", received.peek()));
}
}MemoryPersistence ist wichtig: Die Standard-Dateipersistenz legt pro Client-ID einen Ordner im Arbeitsverzeichnis an, was dein Build-Verzeichnis zumüllt und bei parallelen Läufen kollidieren kann.
Derselbe Test mit dem HiveMQ MQTT Client
Der HiveMQ-Client unterstützt MQTT 5. Seine blockierende API hat eine publishes()-Queue, die ideal für Tests ist: vor dem Abonnieren registrieren, dann receive() mit Timeout aufrufen.
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.*;
import com.hivemq.client.mqtt.MqttGlobalPublishFilter;
import com.hivemq.client.mqtt.datatypes.MqttQos;
import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient;
import com.hivemq.client.mqtt.mqtt5.Mqtt5Client;
import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.*;
class HiveMqRoundTripTest {
Mqtt5BlockingClient client;
String topic;
@BeforeEach
void connect() {
String id = UUID.randomUUID().toString().substring(0, 8);
topic = "testmqtt/junit/" + id + "/status";
client = Mqtt5Client.builder()
.identifier("junit-" + id)
.serverHost("broker.connectmq.com")
.serverPort(1883)
.buildBlocking();
client.connect();
}
@AfterEach
void disconnect() {
client.disconnect();
}
@Test
void statusMessageIsDelivered() throws InterruptedException {
try (var publishes = client.publishes(MqttGlobalPublishFilter.ALL)) {
client.subscribeWith().topicFilter(topic).qos(MqttQos.AT_LEAST_ONCE).send();
client.publishWith().topic(topic).qos(MqttQos.AT_LEAST_ONCE)
.payload("online".getBytes(UTF_8)).send();
Optional<Mqtt5Publish> msg = publishes.receive(5, TimeUnit.SECONDS);
assertTrue(msg.isPresent(), "no message within 5 s on " + topic);
assertEquals("online", new String(msg.get().getPayloadAsBytes(), UTF_8));
}
}
}Für TLS ergänzt du .sslWithDefaultConfig() und Port 8883; für Zugangsdaten .simpleAuth().username(...).password(...).applySimpleAuth() am Connect-Aufruf.
Ein Wegwerf-Broker mit Testcontainers
Für Tests, die offline laufen müssen oder eine bestimmte Broker-Konfiguration brauchen, startest du einen Broker-Container pro Testklasse. Mosquitto 2.x lauscht ohne Konfiguration nur auf localhost, also nimm die mitgelieferte No-Auth-Konfiguration:
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers
class LocalBrokerTest {
@Container
static GenericContainer<?> mosquitto =
new GenericContainer<>(DockerImageName.parse("eclipse-mosquitto:2.0"))
.withExposedPorts(1883)
.withCommand("mosquitto", "-c", "/mosquitto-no-auth.conf")
.waitingFor(Wait.forLogMessage(".*mosquitto version.*running.*", 1));
static String brokerUrl() {
return "tcp://" + mosquitto.getHost() + ":" + mosquitto.getMappedPort(1883);
}
@Test
void brokerAcceptsConnections() throws Exception {
var client = new org.eclipse.paho.client.mqttv3.MqttClient(
brokerUrl(), "junit-local",
new org.eclipse.paho.client.mqttv3.persist.MemoryPersistence());
client.connect();
client.disconnect();
client.close();
}
}Ein static-Container startet einmal pro Klasse; ein Instanzfeld würde pro Testmethode einen starten, was langsam ist. Testcontainers bringt außerdem ein HiveMQ-Modul (org.testcontainers:hivemq) mit, falls du gegen HiveMQ CE testen willst. Die Abwägungen beschreibt MQTT-Tests mit Testcontainers.
Lokaler Container oder gehosteter Broker?
Ein Container beweist, dass dein Code gegen einen Broker funktioniert. Er beweist nicht, dass er gegen deinen Broker funktioniert — den mit TLS, Authentifizierung, ACLs und dem Netzwerkpfad deiner Geräte. Eine sinnvolle Aufteilung:
- Unit- und Komponententests: Testcontainers, keine Netzwerkabhängigkeit, schnelles Feedback.
- Integrationstests in der CI: ein gehosteter Test-Broker mit derselben TLS- und Zugangsdaten-Konfiguration wie in Produktion, ausgewählt über
MQTT_URL,MQTT_USERundMQTT_PASS. - Smoke-Tests nach dem Deployment: der Produktions-Broker, ein eigenes Test-Topic, nur lesende Assertions.
Negativfälle testen
Integrationstests sollten auch beweisen, was nicht passieren darf. Zwei Muster:
- Erwarteter Verbindungsfehler. Mit Paho prüft
assertThrows(MqttSecurityException.class, () -> client.connect(badOptions)), dass falsche Zugangsdaten abgelehnt werden;getReasonCode()liefert 4 oder 5 für "bad username or password" und "not authorized" in MQTT 3.1.1. Jeden anderen Code schlägst du mit dem Reason-Code-Tool nach. - Erwartete Stille. Um zu prüfen, dass eine ACL ein Topic blockiert, abonnierst du, wartest eine begrenzte Zeit und prüfst, dass die Queue leer bleibt:
await().during(Duration.ofSeconds(2)).atMost(Duration.ofSeconds(3)).until(received::isEmpty). Mitduringprüft Awaitility, dass die Bedingung dauerhaft wahr bleibt.
Die Suite ausführen
Markiere netzwerkabhängige Tests mit @Tag("integration") und führe sie mit Maven Failsafe (*IT.java) oder einem eigenen Gradle-Task aus, damit mvn test schnell und offline bleibt. In der CI exportierst du die Broker-Variablen und startest mvn verify; MQTT-Tests in GitHub Actions zeigt einen kompletten Workflow.
Während ein Test läuft, öffne den Online-MQTT-Client und abonniere testmqtt/junit/#, um den Traffic deiner Suite zu sehen — der schnellste Weg herauszufinden, ob ein fehlschlagender Test nie publiziert hat oder auf das falsche Topic.
Traffic deiner JUnit-Tests live ansehen
Gute MQTT-Tests mit JUnit 5 laufen auf Isolation und Geduld hinaus: eine eindeutige Client-ID und ein eindeutiges Topic pro Test, ein bestätigtes Abo vor dem Publizieren und begrenzte Wartezeiten statt Sleeps. Halte die Broker-Adresse konfigurierbar, dann deckt dieselbe Suite einen Container auf dem Laptop, einen CI-Broker und einen Smoke-Test nach dem Deployment ab. Die übergreifende Strategie findest du unter MQTT-Integrationstests.
Häufig gestellte Fragen
Sollte ich Thread.sleep in MQTT-Tests verwenden?
Nein. Ein fester Sleep ist auf einem langsamen CI-Runner zu kurz oder verschwendet auf einem schnellen Zeit. Nutze Awaitility oder ein CompletableFuture mit Timeout: Der Test läuft weiter, sobald die Nachricht ankommt, und scheitert mit einer klaren Meldung, wenn sie ausbleibt.
Paho oder HiveMQ-Client für JUnit-Tests?
Teste mit dem Client, den deine Anwendung nutzt. Eclipse Paho (mqttv3) ist für MQTT 3.1.1 am verbreitetsten; der HiveMQ MQTT Client unterstützt MQTT 5 und bietet blockierende, asynchrone und reaktive APIs. Die Muster in diesem Guide funktionieren mit beiden.
Wie verhindere ich, dass parallele MQTT-Tests sich gegenseitig stören?
Gib jedem Test eine eindeutige Client-ID und ein eindeutiges Topic-Präfix, zum Beispiel testmqtt/junit/<zufalls-id>/. Dann laufen Tests parallel gegen denselben Broker, ohne fremde Nachrichten zu empfangen oder sich durch doppelte Client-IDs gegenseitig zu trennen.


