Broker-Tests

MQTT-Integrationstests in Java mit JUnit 5

Zuverlässige MQTT-Integrationstests in Java mit JUnit 5: Beispiele mit Eclipse Paho und HiveMQ-Client, Assertions mit Awaitility und Broker per Testcontainers.

Aktualisiert · 9 Min. Lesezeit

MQTT-Integrationstests in Java mit JUnit 5

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.

pom.xml (test dependencies)
<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

RegelWarum
Eindeutige Client-ID pro TestZwei Clients mit derselben ID trennen sich gegenseitig ("session taken over")
Eindeutiges Topic-Präfix pro TestParallele Tests und alte Retained Messages landen nicht in deinen Assertions
Erst abonnieren, auf SUBACK warten, dann publizierenEine Nachricht, die vor dem aktiven Abo publiziert wird, wird schlicht nicht zugestellt
Mit Timeout warten, nie mit Thread.sleepSchnell, wenn es klappt, ein klarer Fehler, wenn nicht
In @AfterEach trennenVergessene Verbindungen erschöpfen Broker-Limits und halten Sessions am Leben
Broker-Adresse aus der KonfigurationDieselben 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.

PahoRoundTripTest.java
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.

HiveMqRoundTripTest.java
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:

LocalBrokerTest.java
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_USER und MQTT_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). Mit during prü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.