Zum Inhalt
wannacode

API und Benutzeroberfläche

Kurz gesagt: Ein Komponententest ruft die Regel auf, sieht aber nicht, wie der Server sie benutzt. Ein API-Test schickt eine Anfrage wie ein echter Client und prüft den Statuscode und den Body: So fängt er einen Server, der die Regel kopiert und die Ablehnung vergessen hat. Ein UI-Test öffnet einen Browser und findet Buttons nach Rolle und Name, so wie ein Mensch sie sieht. Er ist langsam und brüchig, also gibt es wenige davon, und sie laufen nicht bei jeder Änderung.

Problem

Alles ist grün, und der leere Warenkorb geht durch

Die Szenarien bestehen. Die Komponententests bestehen. Am Freitag bringt der Shop eine neue Version heraus, und am Montag stehen in der Datenbank Bestellungen über 0,00 € mit bezahlten Lieferkosten von 4,90 €.

Die Regel in der Lieferklasse ist richtig. Aber der Programmierer auf dem Server hat „optimiert“ und die Gebühr direkt in dem Code berechnet, der die Anfragen beantwortet. Die Ablehnung hat er nicht kopiert. Kein Test ging durch den Server.

Die Idee

Der API-Test

Der Server des Shops beantwortet Anfragen. Die Kunden-App fragt GET /delivery?total=4999 und bekommt die Gebühr. Für einen leeren Warenkorb bekommt sie den Statuscode 400 und eine Erklärung:

src/main/java/shop/ShopApi.java
private static void delivery(HttpExchange exchange) throws IOException {
int total = Integer.parseInt(exchange.getRequestURI().getQuery().replace("total=", ""));
try {
send(exchange, 200, String.valueOf(Delivery.feeFor(total)));
} catch (IllegalArgumentException e) {
send(exchange, 400, e.getMessage());
}
}

Ein API-Test ist ein Client dieses Servers. Er startet den Server, schickt eine echte HTTP-Anfrage und prüft zwei Dinge: den Statuscode und den Body. Er sieht das Innere des Servers nicht, nur seinen Vertrag mit dem Client:

src/test/java/shop/DeliveryApiTest.java
@Test
@DisplayName("leerer Warenkorb → 400")
void emptyCartIsRefused() throws Exception {
HttpResponse<String> response = get(0);
assertEquals(400, response.statusCode());
assertEquals("Ein leerer Warenkorb kann nicht bestellt werden", response.body());
}

Der Server startet im selben Prozess, auf einem freien Port, den das System wählt. So hängt der Test von nichts außerhalb seiner selbst ab und kann überall laufen.

Genauer, für Neugierige

Die Server hier sind die kleinsten, die jede Sprache eingebaut hat: com.sun.net.httpserver in Java, ASP.NET Core Minimal API in C#, http.server in Python und node:http in JavaScript. In einem echten Projekt ist der Server groß, aber der Test ist derselbe: eine Anfrage, ein Statuscode, ein Body. Java und C# haben auch Werkzeuge, die die Anwendung im Testmodus ohne echtes Netz starten (zum Beispiel WebApplicationFactory in ASP.NET Core). Python und JavaScript haben eigene (TestClient in FastAPI, supertest).

Die Idee

Der Lauf

Die ganze Datei mit API-Tests, gegen den Server, der die Regel aufruft. Alles besteht:

src/test/java/shop/DeliveryApiTest.java
package shop;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DeliveryApiTest {
private static HttpServer server;
private static final HttpClient client = HttpClient.newHttpClient();
@BeforeAll
static void start() throws Exception {
server = ShopApi.start(0);
}
@AfterAll
static void stop() {
server.stop(0);
}
private HttpResponse<String> get(int total) throws Exception {
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/delivery?total=" + total);
return client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString());
}
@Test
@DisplayName("49,99 € → Lieferung 4,90 €")
void paidDelivery() throws Exception {
HttpResponse<String> response = get(4999);
assertEquals(200, response.statusCode());
assertEquals("490", response.body());
}
@Test
@DisplayName("50,00 € → kostenlose Lieferung")
void freeDelivery() throws Exception {
HttpResponse<String> response = get(5000);
assertEquals(200, response.statusCode());
assertEquals("0", response.body());
}
@Test
@DisplayName("leerer Warenkorb → 400")
void emptyCartIsRefused() throws Exception {
HttpResponse<String> response = get(0);
assertEquals(400, response.statusCode());
assertEquals("Ein leerer Warenkorb kann nicht bestellt werden", response.body());
}
}
Ausgabe
OK 49,99 € → Lieferung 4,90 €
OK 50,00 € → kostenlose Lieferung
OK leerer Warenkorb → 400
Tests: 3, bestanden: 3, fehlgeschlagen: 0

Jetzt der Server vom Anfang der Lektion: Die Regel ist kopiert, ohne die Ablehnung.

src/main/java/shop/ShopApi.java
private static void delivery(HttpExchange exchange) throws IOException {
int total = Integer.parseInt(exchange.getRequestURI().getQuery().replace("total=", ""));
// Die Regel ist hier kopiert, statt Delivery aufzurufen.
int fee = total >= Delivery.FREE_FROM ? 0 : Delivery.FEE;
send(exchange, 200, String.valueOf(fee));
}

Dieselben Tests schlagen beim leeren Warenkorb fehl. Der Server sagt 200, und er sollte 400 sagen:

src/test/java/shop/DeliveryApiTest.java
package shop;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DeliveryApiTest {
private static HttpServer server;
private static final HttpClient client = HttpClient.newHttpClient();
@BeforeAll
static void start() throws Exception {
server = ShopApi.start(0);
}
@AfterAll
static void stop() {
server.stop(0);
}
private HttpResponse<String> get(int total) throws Exception {
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/delivery?total=" + total);
return client.send(HttpRequest.newBuilder(uri).build(), HttpResponse.BodyHandlers.ofString());
}
@Test
@DisplayName("49,99 € → Lieferung 4,90 €")
void paidDelivery() throws Exception {
HttpResponse<String> response = get(4999);
assertEquals(200, response.statusCode());
assertEquals("490", response.body());
}
@Test
@DisplayName("50,00 € → kostenlose Lieferung")
void freeDelivery() throws Exception {
HttpResponse<String> response = get(5000);
assertEquals(200, response.statusCode());
assertEquals("0", response.body());
}
@Test
@DisplayName("leerer Warenkorb → 400")
void emptyCartIsRefused() throws Exception {
HttpResponse<String> response = get(0);
assertEquals(400, response.statusCode());
assertEquals("Ein leerer Warenkorb kann nicht bestellt werden", response.body());
}
}
Ausgabe
FEHLER leerer Warenkorb → 400: expected: <400> but was: <200>
OK 49,99 € → Lieferung 4,90 €
OK 50,00 € → kostenlose Lieferung
Tests: 3, bestanden: 2, fehlgeschlagen: 1

Die Komponententests aus der vorigen Lektion bestehen weiterhin, weil die Lieferklasse nicht angefasst wurde. Jede Stufe fängt ihre eigenen Fehler.

Probier es aus

Was sieht nur der API-Test?

1. Die Komponententests bestehen. Was kann außerdem kaputt sein, das nur der API-Test sieht?
2. Ein leerer Warenkorb muss abgelehnt werden. Der API-Test prüft nur den Body „Ein leerer Warenkorb kann nicht bestellt werden“. Was übersieht er?
3. Warum startet der API-Test den Server auf einem freien Port (Port 0) und nicht auf 8080?

Die Idee

Der UI-Test mit Playwright

Ein UI-Test öffnet einen echten Browser und tut, was der Kunde tut: öffnet den Warenkorb, drückt „Bestellen“, liest, was dort steht. Das Werkzeug hier ist Playwright. Es hat Versionen für alle vier Sprachen:

src/test/java/shop/CheckoutUiTest.java
package shop;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.AriaRole;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
@Tag("ui")
class CheckoutUiTest {
@Test
@DisplayName("Bestellung genau an der Grenze")
void freeDeliveryOnTheBoundary() {
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().launch();
Page page = browser.newPage();
// ein Warenkorb von 50,00 €
page.navigate("http://localhost:4100/cart?test-cart=5000");
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Bestellen")).click();
assertThat(page.getByRole(AriaRole.HEADING, new Page.GetByRoleOptions().setName("Bestellung angenommen"))).isVisible();
assertThat(page.getByText("Lieferung: kostenlos")).isVisible();
}
}
}

Achte darauf, wie der Test den Button findet: nach Rolle und Name („ein Button mit dem Namen Bestellen“), nicht nach CSS (#app > div:nth-child(3) > button). Rolle und Name sind das, was der Kunde sieht und was ein Screenreader einem blinden Menschen sagt. Sie ändern sich, wenn sich die Bedeutung ändert. CSS ändert sich, wenn der Designer etwas verschiebt.

Die Prüfungen in Playwright warten von selbst: „ist die Überschrift sichtbar“ wartet bis zu ein paar Sekunden, bis sie erscheint. Deshalb gibt es im Test keine Pausen.

Genauer, für Neugierige

Der Testwarenkorb (?test-cart=5000) ist eine Absprache mit den Programmierern: In der Testumgebung kann der Shop mit einem fertigen Warenkorb starten, damit der UI-Test nicht Produkte suchen und hinzufügen muss. Das ist ein Teil des Tests, der nicht in Playwright lebt, sondern im Shop.

Playwright stammt von Microsoft und ist Open Source. Andere verbreitete Werkzeuge sind Selenium (das älteste) und Cypress (nur JavaScript). Das Finden nach Rolle wurde durch die Testing Library populär, und die Rollen sind die des Barrierefreiheitsstandards ARIA.

Die Idee

Warum UI-Tests kaputtgehen und warum es wenige gibt

Ein UI-Test ist dem Kunden am nächsten und am teuersten:

  • langsam: ein Browser, ein Netz, eine Seite. Sekunden pro Test, nicht Millisekunden;
  • braucht alles: einen laufenden Shop, eine Datenbank, Testdaten;
  • brüchig: Er schlägt wegen eines verschobenen Buttons, eines langsamen Netzes, einer Animation fehl. Ein brüchiger Test schlägt ohne Fehler fehl, und die Leute hören auf, ihm zu trauen.

Deshalb gibt es wenige UI-Tests: nur die End-to-End-Pfade, die nicht kaputtgehen dürfen. Die Regeln werden weiter unten geprüft: die Beträge auf Komponentenebene, der Vertrag des Servers mit einem API-Test.

Dieser UI-Test läuft im Kurs nicht: Er braucht einen Browser und einen laufenden Shop, die der Prüfer des Kurses nicht hat. Aus demselben Grund sind in der CI-Konfiguration der nächsten Lektion die UI-Tests abgesondert und laufen einmal pro Nacht gegen die Testumgebung, nicht bei jeder Änderung. Die Szenarien, die Komponententests und die API-Tests in diesem Modul laufen wirklich.

Probier es aus

Welcher Locator übersteht es?

1. Welcher Locator übersteht es, wenn der Designer den Button „Bestellen“ in einen anderen Block verschiebt und seine Klassen ändert?
2. Der Button ist <div class="btn" onclick="…">Bestellen</div>. Ein Test, der nach der Rolle „button“ sucht, findet ihn nicht. Was heißt das?
3. Nach „Bestellen“ erscheint die Bestätigung nach einer halben Sekunde. Wie wartet der Test darauf?

Übung

Welche Stufe wofür

1. API, UI oder weiter unten. Der Shop sortiert seine Tests.

1. Was prüft man am besten mit einem API-Test?
2. Der Shop hat 400 UI-Tests, sie laufen 2 Stunden, und jeden Tag schlagen fünf oder sechs ohne Fehler fehl. Was schlägst du vor?
3. Welcher UI-Test ist am meisten wert?

Test

Teste dich

1. Was prüft ein API-Test?
2. Warum findet ein UI-Test Elemente nach Rolle und Name und nicht nach CSS?
3. Warum gibt es wenige UI-Tests?
4. Der Server hat die Regel kopiert und die Ablehnung vergessen. Welcher Test fängt das?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·