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:
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()); }}app.MapGet("/delivery", (int total) =>{ try { return Results.Text(Delivery.FeeFor(total).ToString()); } catch (ArgumentException e) { return Results.Text(e.Message, statusCode: 400); }});def do_GET(self): total = int(parse_qs(urlparse(self.path).query)["total"][0]) try: self.send(200, str(delivery_fee(total))) except ValueError as e: self.send(400, str(e))const server = createServer((req, res) => { const total = Number(new URL(req.url, "http://localhost").searchParams.get("total")); try { send(res, 200, String(deliveryFee(total))); } catch (e) { send(res, 400, e.message); }});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:
@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());}[Fact(DisplayName = "leerer Warenkorb → 400")]public async Task EmptyCartIsRefused(){ var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Ein leerer Warenkorb kann nicht bestellt werden", await response.Content.ReadAsStringAsync());}def test_empty_cart_is_refused(base_url): status, text = get(base_url, 0) assert status == 400 assert text == "Ein leerer Warenkorb kann nicht bestellt werden"test("leerer Warenkorb → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Ein leerer Warenkorb kann nicht bestellt werden");});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:
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()); }}OK 49,99 € → Lieferung 4,90 €OK 50,00 € → kostenlose LieferungOK leerer Warenkorb → 400Tests: 3, bestanden: 3, fehlgeschlagen: 0using Microsoft.AspNetCore.Builder;using Xunit;
namespace Shop;
public class DeliveryApiTest : IAsyncLifetime{ private WebApplication app; private readonly HttpClient client = new();
public async Task InitializeAsync() { app = ShopApi.Create(); app.Urls.Add("http://127.0.0.1:0"); await app.StartAsync(); client.BaseAddress = new Uri(app.Urls.First()); }
public async Task DisposeAsync() => await app.StopAsync();
[Fact(DisplayName = "49,99 € → Lieferung 4,90 €")] public async Task PaidDelivery() { var response = await client.GetAsync("/delivery?total=4999"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("490", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "50,00 € → kostenlose Lieferung")] public async Task FreeDelivery() { var response = await client.GetAsync("/delivery?total=5000"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("0", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "leerer Warenkorb → 400")] public async Task EmptyCartIsRefused() { var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Ein leerer Warenkorb kann nicht bestellt werden", await response.Content.ReadAsStringAsync()); }}OK 49,99 € → Lieferung 4,90 €OK 50,00 € → kostenlose LieferungOK leerer Warenkorb → 400Tests: 3, bestanden: 3, fehlgeschlagen: 0import urllib.requestfrom urllib.error import HTTPError
import pytest
from shop_api import start
@pytest.fixture(scope="module")def base_url(): server = start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown()
def get(base_url, total): try: with urllib.request.urlopen(f"{base_url}/delivery?total={total}") as response: return response.status, response.read().decode() except HTTPError as e: return e.code, e.read().decode()
def test_paid_delivery(base_url): status, text = get(base_url, 4999) assert status == 200 assert text == "490"
def test_free_delivery(base_url): status, text = get(base_url, 5000) assert status == 200 assert text == "0"
def test_empty_cart_is_refused(base_url): status, text = get(base_url, 0) assert status == 400 assert text == "Ein leerer Warenkorb kann nicht bestellt werden"OK test_empty_cart_is_refusedOK test_free_deliveryOK test_paid_deliveryTests: 3, bestanden: 3, fehlgeschlagen: 0import { after, before, test } from "node:test";import assert from "node:assert/strict";import { start } from "./shop-api.js";
let server;let base;
before(async () => { server = await start(); base = `http://127.0.0.1:${server.address().port}`;});
after(() => server.close());
test("49,99 € → Lieferung 4,90 €", async () => { const response = await fetch(`${base}/delivery?total=4999`); assert.equal(response.status, 200); assert.equal(await response.text(), "490");});
test("50,00 € → kostenlose Lieferung", async () => { const response = await fetch(`${base}/delivery?total=5000`); assert.equal(response.status, 200); assert.equal(await response.text(), "0");});
test("leerer Warenkorb → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Ein leerer Warenkorb kann nicht bestellt werden");});OK 49,99 € → Lieferung 4,90 €OK 50,00 € → kostenlose LieferungOK leerer Warenkorb → 400Tests: 3, bestanden: 3, fehlgeschlagen: 0Jetzt der Server vom Anfang der Lektion: Die Regel ist kopiert, ohne die Ablehnung.
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));}app.MapGet("/delivery", (int total) =>{ // Die Regel ist hier kopiert, statt Delivery aufzurufen. int fee = total >= Delivery.FreeFrom ? 0 : Delivery.Fee; return Results.Text(fee.ToString());});def do_GET(self): total = int(parse_qs(urlparse(self.path).query)["total"][0]) # Die Regel ist hier kopiert, statt Delivery aufzurufen. fee = 0 if total >= FREE_FROM else FEE self.send(200, str(fee))const server = createServer((req, res) => { const total = Number(new URL(req.url, "http://localhost").searchParams.get("total")); // Die Regel ist hier kopiert, statt Delivery aufzurufen. const fee = total >= FREE_FROM ? 0 : FEE; send(res, 200, String(fee));});Dieselben Tests schlagen beim leeren Warenkorb fehl. Der Server sagt 200, und er sollte 400 sagen:
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()); }}FEHLER leerer Warenkorb → 400: expected: <400> but was: <200>OK 49,99 € → Lieferung 4,90 €OK 50,00 € → kostenlose LieferungTests: 3, bestanden: 2, fehlgeschlagen: 1using Microsoft.AspNetCore.Builder;using Xunit;
namespace Shop;
public class DeliveryApiTest : IAsyncLifetime{ private WebApplication app; private readonly HttpClient client = new();
public async Task InitializeAsync() { app = ShopApi.Create(); app.Urls.Add("http://127.0.0.1:0"); await app.StartAsync(); client.BaseAddress = new Uri(app.Urls.First()); }
public async Task DisposeAsync() => await app.StopAsync();
[Fact(DisplayName = "49,99 € → Lieferung 4,90 €")] public async Task PaidDelivery() { var response = await client.GetAsync("/delivery?total=4999"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("490", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "50,00 € → kostenlose Lieferung")] public async Task FreeDelivery() { var response = await client.GetAsync("/delivery?total=5000"); Assert.Equal(200, (int)response.StatusCode); Assert.Equal("0", await response.Content.ReadAsStringAsync()); }
[Fact(DisplayName = "leerer Warenkorb → 400")] public async Task EmptyCartIsRefused() { var response = await client.GetAsync("/delivery?total=0"); Assert.Equal(400, (int)response.StatusCode); Assert.Equal("Ein leerer Warenkorb kann nicht bestellt werden", await response.Content.ReadAsStringAsync()); }}FEHLER leerer Warenkorb → 400: Assert.Equal() Failure: Values differ Expected: 400 Actual: 200OK 49,99 € → Lieferung 4,90 €OK 50,00 € → kostenlose LieferungTests: 3, bestanden: 2, fehlgeschlagen: 1import urllib.requestfrom urllib.error import HTTPError
import pytest
from shop_api import start
@pytest.fixture(scope="module")def base_url(): server = start() yield f"http://127.0.0.1:{server.server_address[1]}" server.shutdown()
def get(base_url, total): try: with urllib.request.urlopen(f"{base_url}/delivery?total={total}") as response: return response.status, response.read().decode() except HTTPError as e: return e.code, e.read().decode()
def test_paid_delivery(base_url): status, text = get(base_url, 4999) assert status == 200 assert text == "490"
def test_free_delivery(base_url): status, text = get(base_url, 5000) assert status == 200 assert text == "0"
def test_empty_cart_is_refused(base_url): status, text = get(base_url, 0) assert status == 400 assert text == "Ein leerer Warenkorb kann nicht bestellt werden"FEHLER test_empty_cart_is_refused: assert 200 == 400OK test_free_deliveryOK test_paid_deliveryTests: 3, bestanden: 2, fehlgeschlagen: 1import { after, before, test } from "node:test";import assert from "node:assert/strict";import { start } from "./shop-api.js";
let server;let base;
before(async () => { server = await start(); base = `http://127.0.0.1:${server.address().port}`;});
after(() => server.close());
test("49,99 € → Lieferung 4,90 €", async () => { const response = await fetch(`${base}/delivery?total=4999`); assert.equal(response.status, 200); assert.equal(await response.text(), "490");});
test("50,00 € → kostenlose Lieferung", async () => { const response = await fetch(`${base}/delivery?total=5000`); assert.equal(response.status, 200); assert.equal(await response.text(), "0");});
test("leerer Warenkorb → 400", async () => { const response = await fetch(`${base}/delivery?total=0`); assert.equal(response.status, 400); assert.equal(await response.text(), "Ein leerer Warenkorb kann nicht bestellt werden");});FEHLER leerer Warenkorb → 400: 200 !== 400OK 49,99 € → Lieferung 4,90 €OK 50,00 € → kostenlose LieferungTests: 3, bestanden: 2, fehlgeschlagen: 1Die 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?
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:
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(); } }}using Microsoft.Playwright;using Xunit;
namespace Shop;
public class CheckoutUiTest{ [Fact(DisplayName = "Bestellung genau an der Grenze")] [Trait("Category", "UI")] public async Task FreeDeliveryOnTheBoundary() { using var playwright = await Playwright.CreateAsync(); await using var browser = await playwright.Chromium.LaunchAsync(); var page = await browser.NewPageAsync(); // ein Warenkorb von 50,00 € await page.GotoAsync("http://localhost:4100/cart?test-cart=5000"); await page.GetByRole(AriaRole.Button, new() { Name = "Bestellen" }).ClickAsync(); await Assertions.Expect(page.GetByRole(AriaRole.Heading, new() { Name = "Bestellung angenommen" })).ToBeVisibleAsync(); await Assertions.Expect(page.GetByText("Lieferung: kostenlos")).ToBeVisibleAsync(); }}import pytestfrom playwright.sync_api import Page, expect
@pytest.mark.uidef test_free_delivery_on_the_boundary(page: Page): # ein Warenkorb von 50,00 € page.goto("http://localhost:4100/cart?test-cart=5000") page.get_by_role("button", name="Bestellen").click() expect(page.get_by_role("heading", name="Bestellung angenommen")).to_be_visible() expect(page.get_by_text("Lieferung: kostenlos")).to_be_visible()import { test, expect } from "@playwright/test";
test("Bestellung genau an der Grenze", async ({ page }) => { // ein Warenkorb von 50,00 € await page.goto("http://localhost:4100/cart?test-cart=5000"); await page.getByRole("button", { name: "Bestellen" }).click(); await expect(page.getByRole("heading", { name: "Bestellung angenommen" })).toBeVisible(); await expect(page.getByText("Lieferung: kostenlos")).toBeVisible();});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?
Übung
Welche Stufe wofür
1. API, UI oder weiter unten. Der Shop sortiert seine Tests.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Usability und Barrierefreiheit · Warum und was automatisieren · Vom Szenario zu den Schritten · Komponententests
- Geht weiter in: Tests in der CI
- Aufgaben zum Thema: Automatisierung