← Notizen

WebClient mit MockWebServer testen: Status, Medientyp und Dekodierung

Auf dieser Seite

Testen Sie WebClient mit einem lokalen MockWebServer und prüfen Sie HTTP-Status, Content-Type der Antwort, Body-Dekodierung sowie Abschluss oder Fehler gemeinsam. Eine 200 OK-Antwort mit HTML kann den Antwortvertrag des Clients verletzen, obwohl der Status Erfolg signalisiert; das Senden von Accept: application/json erzwingt keine JSON-Antwort. Spring dokumentiert Tests mit einem Mock-Webserver und dem echten HTTP-Client.

Die Tests verwenden einen echten WebClient über lokales HTTP statt eines Mocks seiner verketteten Methoden. Sie decken die unten gezeigten Antwortverträge ab; Timeout- und Wiederholungsrichtlinien für den Produktivbetrieb benötigen separate Tests.

Verarbeitungsablauf

HTTP-Antwort validieren

Der Client prüft den Status vor dem Medientyp, gibt abgelehnte Bodys frei und dekodiert unterstütztes JSON. StepVerifier prüft neben ausgegebenen Werten auch Abschluss oder Fehler.

Ein eigenständiges Testprojekt einrichten

Die Reproduktionsumgebung besteht aus JDK 17.0.8.1, Gradle 8.4, Spring Boot 3.5.7 BOM und MockWebServer 4.12.0. Diese fixieren die Abhängigkeiten des Beispiels; sie sind keine Empfehlung aktueller Produktionsversionen. Dies ist ein einfaches Java-Testprojekt; eine laufende Spring-Boot-Anwendung ist nicht erforderlich.

Erstellen Sie in einem neuen Verzeichnis eine settings.gradle mit rootProject.name = 'webclient-example' sowie diese vollständige build.gradle:

plugins { id 'java' }
repositories { mavenCentral() }
dependencies {
    testImplementation platform('org.springframework.boot:spring-boot-dependencies:3.5.7')
    testImplementation 'org.springframework.boot:spring-boot-starter-webflux'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
    testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test { useJUnitPlatform() }

Die BOM ist ausdrücklich angegeben, damit die Spring- und Reactor-Abhängigkeiten auch ohne Spring-Boot-Gradle-Plugin Versionen erhalten. Passen Sie bei einer bereits von Boot verwalteten Anwendung die Testabhängigkeiten an deren ausgewählte BOM an.

Acht Antwortverträge testen

Speichern Sie diese vollständige Klasse als src/test/java/ExternalApiTest.java:

import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.junit.jupiter.api.Assertions.*;

class ExternalApiTest {
    private MockWebServer server;
    private WebClient client;
    record Payload(String message) {}

    @BeforeEach
    void start() throws IOException {
        server = new MockWebServer();
        server.start();
        client = WebClient.builder().baseUrl(server.url("/").toString()).build();
    }

    @AfterEach
    void stop() throws IOException {
        server.shutdown();
    }

    private Mono<Payload> fetch() {
        return client.get().uri("/employee/100")
            .accept(MediaType.APPLICATION_JSON)
            .exchangeToMono(response -> {
                if (response.statusCode().isError()) {
                    return response.createException().flatMap(Mono::error);
                }
                boolean json = response.headers().contentType()
                    .map(type -> "application".equals(type.getType())
                        && ("json".equals(type.getSubtype())
                            || type.getSubtype().endsWith("+json")))
                    .orElse(false);
                if (!json) {
                    return response.releaseBody().then(Mono.<Payload>error(
                        new IllegalStateException("Expected JSON response")));
                }
                return response.bodyToMono(Payload.class);
            });
    }

    @Test
    void rejectsHtmlEvenWhenStatusIs200() throws Exception {
        server.enqueue(new MockResponse().setResponseCode(200)
            .setHeader("Content-Type", "text/html; charset=UTF-8")
            .setBody("<html><body>Unknown failure</body></html>"));
        StepVerifier.create(fetch())
            .expectErrorMatches(error -> error instanceof IllegalStateException
                && error.getMessage().equals("Expected JSON response"))
            .verify(Duration.ofSeconds(5));
        RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS);
        assertNotNull(request);
        assertEquals("GET", request.getMethod());
        assertEquals("/employee/100", request.getPath());
        assertEquals("application/json", request.getHeader("Accept"));
    }

    @Test
    void preservesHttp500Error() {
        server.enqueue(new MockResponse().setResponseCode(500)
            .setHeader("Content-Type", "text/html")
            .setBody("<html>Unknown failure</html>"));
        StepVerifier.create(fetch())
            .expectError(org.springframework.web.reactive.function.client.WebClientResponseException.InternalServerError.class)
            .verify(Duration.ofSeconds(5));
    }

    @Test
    void decodesJson() {
        server.enqueue(new MockResponse().setResponseCode(200)
            .setHeader("Content-Type", "application/json")
            .setBody("{\"message\":\"ok\"}"));
        StepVerifier.create(fetch())
            .expectNextMatches(payload -> payload.message().equals("ok"))
            .expectComplete().verify(Duration.ofSeconds(5));
    }

    @Test
    void rejectsMissingContentType() {
        server.enqueue(new MockResponse().setResponseCode(200)
            .setBody("{\"message\":\"ok\"}"));
        StepVerifier.create(fetch())
            .expectErrorMatches(error -> error instanceof IllegalStateException
                && error.getMessage().equals("Expected JSON response"))
            .verify(Duration.ofSeconds(5));
    }

    @Test
    void rejectsMalformedJson() {
        server.enqueue(new MockResponse().setResponseCode(200)
            .setHeader("Content-Type", "application/json").setBody("{broken"));
        StepVerifier.create(fetch())
            .expectError(org.springframework.core.codec.DecodingException.class)
            .verify(Duration.ofSeconds(5));
    }

    @Test
    void decodesStructuredJsonSubtype() {
        server.enqueue(new MockResponse().setResponseCode(200)
            .setHeader("Content-Type", "application/vnd.example+json")
            .setBody("{\"message\":\"ok\"}"));
        StepVerifier.create(fetch())
            .expectNextMatches(payload -> payload.message().equals("ok"))
            .expectComplete().verify(Duration.ofSeconds(5));
    }

    @Test
    void completesWithoutPayloadForEmptyJsonBody() {
        server.enqueue(new MockResponse().setResponseCode(200)
            .setHeader("Content-Type", "application/json"));
        StepVerifier.create(fetch())
            .expectComplete().verify(Duration.ofSeconds(5));
    }

    @Test
    void rejectsNonApplicationJsonSubtype() {
        server.enqueue(new MockResponse().setResponseCode(200)
            .setHeader("Content-Type", "text/example+json")
            .setBody("{\"message\":\"ok\"}"));
        StepVerifier.create(fetch())
            .expectErrorMatches(error -> error instanceof IllegalStateException
                && error.getMessage().equals("Expected JSON response"))
            .verify(Duration.ofSeconds(5));
    }
}

Führen Sie dies mit ausgewähltem JDK 17 und installiertem Gradle 8.4 aus:

java -version
gradle --version
gradle --no-daemon test

Alle acht Tests sollten bestehen. Öffnen Sie build/reports/tests/test/index.html für die einzelnen Testergebnisse; die Ausgabe der Gradle-Aufgabe muss nicht jeden bestandenen Test auflisten.

Antwort Von diesem Beispiel gefordertes Ergebnis
HTTP 200, JSON-Body Nutzdaten dekodieren.
HTTP 200, HTML-Body Unerwarteten Inhaltstyp ablehnen.
HTTP 500, HTML-Body HTTP-Fehlertyp bewahren.
HTTP 200, Content-Type fehlt Nicht angegebenes Antwortformat ablehnen.
HTTP 200, fehlerhaftes JSON Dekodierfehler melden.
HTTP 200, application/vnd.example+json Kompatible JSON-Nutzdaten dekodieren.
HTTP 200, leerer JSON-Body Ohne Ausgabe von Nutzdaten abschließen.
HTTP 200, text/example+json Medientyp außerhalb von application ablehnen.

Das Verhalten bei leerem Body ist ein bewusster Vertrag dieses Beispiels. Wenn Ihr Endpunkt Nutzdaten erfordert, ergänzen Sie switchIfEmpty(Mono.error(...)) und ändern Sie den Test so, dass er den Anwendungsfehler erwartet.

Warum kann HTTP 200 die Antwortvalidierung trotzdem nicht bestehen?

fetch() behandelt Fehlerstatus, bevor das Format erfolgreicher Antworten geprüft wird. createException() bewahrt HTTP 500 als WebClientResponseException.InternalServerError. Ein Verbindungsfehler folgt gewöhnlich stattdessen dem Pfad WebClientRequestException; er ist keine HTTP-Antwort des Servers.

Bei einem Nichtfehlerstatus akzeptiert der Code die Medientypen application/json und application/*+json und überlässt danach dem Decoder die Body-Validierung. Ein falscher Content-Type und fehlerhaftes JSON erzeugen daher unterschiedliche Fehler. Dies ist ein Beispielvertrag, keine vollständige Richtlinie für Weiterleitungen oder jeden Erfolgsstatus.

Beim Ablehnen einer erfolgreichen Antwort gibt der Code den Body frei, bevor er den Anwendungsfehler meldet. Verarbeiten oder behandeln Sie den Body bei exchangeToMono innerhalb des Callbacks; Springs exchange-Dokumentation erläutert, dass ein nicht konsumierter Body nach Abschluss freigegeben wird.

setBody(html) sendet HTML direkt. Würde dieser String zuvor als JSON serialisiert, änderte sich die getestete Antwort. Die Assertions zur aufgezeichneten Anfrage prüfen außerdem die tatsächlich gesendete Methode, den Pfad und den Header Accept.

verify(Duration.ofSeconds(5)) begrenzt die Wartezeit jedes Tests. Es konfiguriert keine Verbindungs- oder Antwort-Timeouts des Produktionsclients. Ergänzen Sie Transport-Timeout- und Wiederholungstests gemäß Ihrem API-Vertrag; diese acht Fälle decken sie nicht ab.

Alle acht Tests bestanden in der oben genannten Reproduktionsumgebung. In diesem Repository wiederholt python3 scripts/check_webclient_example.py diese Prüfung. Stellen Sie bei einem Projekt über die Jakarta-Grenze hinweg die Spring-Boot-Migrationsbasis her, bevor Sie das Clientverhalten ändern.

Diese Clienttests verwenden einen lokalen Mock-Server; sie prüfen keine statischen Dateien einer bereitgestellten Anwendung. Paketiert die Anwendung Vue-Apps, prüft das Gradle-Beispiel zur Frontend-Paketierung die ausführbare JAR und deren HTML-/JavaScript-Antworten. Die beiden Beispiele fixieren unterschiedliche Umgebungen und sollten erst nach Abstimmung ihrer Abhängigkeiten kombiniert werden.

MockWebServer-API-Referenz: OkHttp-4.12.0-Quellcode und Beispiele.

Für Zeitgrenzen und Wiederholungszahlen fahren Sie mit den WebClient-Tests für Timeouts und begrenzte Wiederholungen fort. Dieses Beispiel wiederholt ausschließlich GET-Antworten mit Status 503 und trennt Grenzen pro Versuch vom Gesamtbudget der Wiederholungen.

Diskussion

Auf GitHub ansehen ↗