Tests in der CI
Kurz gesagt: Die CI lässt die Tests bei jeder Änderung auf einer sauberen Maschine laufen und erlaubt kein Zusammenführen, solange etwas rot ist. Ein fehlgeschlagener Test ist ein Signal: ein Fehler, eine geänderte Regel, ein falscher Test oder eine kaputte Umgebung. Der gefährlichste ist der Test, der manchmal fehlschlägt: Die Leute gewöhnen sich daran, ihn erneut laufen zu lassen, und übersehen den echten Fehlschlag. Die Ursache ist fast immer etwas, das der Test nicht kontrolliert, etwa die Zeit.
Problem
Nachts rot, morgens grün
Jede Nacht um 1 Uhr Sofioter Zeit lässt der Server alle Tests laufen. Drei Nächte hintereinander schlägt der Test „der Gutscheincode gilt an seinem letzten Tag“ fehl. Morgens lässt der Programmierer ihn noch einmal laufen, und er besteht.
„Er schlägt manchmal fehl, lass ihn noch einmal laufen“ wurde zur Gewohnheit. Einen Monat später schaute niemand mehr auf den nächtlichen Bericht. Dann schlug ein echter Rabatttest fehl, und niemand sah es, bis sich der erste Kunde beschwerte.
Die Idee
Continuous Integration
Continuous Integration (CI) ist eine Vereinbarung: Bei jeder Änderung, bevor sie in den gemeinsamen Code kommt, holt ein Server den Code auf eine saubere Maschine, kompiliert ihn und lässt die Tests laufen. Wenn irgendetwas rot ist, wird die Änderung nicht zusammengeführt.
Hier ist die Konfiguration für GitHub Actions, eines der verbreitetsten Werkzeuge. Sie läuft bei jedem Push und jedem Pull Request:
name: Tests
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-java@v6 with: distribution: temurin java-version: 21 # Ohne die UI-Tests (mit ui markiert): Sie laufen getrennt, einmal pro Nacht. - run: mvn --batch-mode test -DexcludedGroups=uiname: Tests
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x # Ohne die UI-Tests (Category=UI): Sie laufen getrennt, einmal pro Nacht. - run: dotnet test --filter "Category!=UI"name: Tests
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: python-version: "3.12" - run: pip install -r requirements.txt # Ohne die UI-Tests (mit ui markiert): Sie laufen getrennt, einmal pro Nacht. - run: pytest -m "not ui" --junitxml=report.xml && behavename: Tests
on: push: pull_request:
jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci # Die UI-Tests (tests/*.spec.js) sind nicht hier: Sie laufen mit Playwright getrennt, einmal pro Nacht. - run: node --test && npx cucumber-jsDie Komponententests, die Szenarien und die API-Tests aus diesem Modul laufen hier. Die UI-Tests sind getrennt, einmal pro Nacht gegen die Testumgebung: Sie brauchen einen Browser und einen laufenden Shop und sind für jede Änderung zu langsam.
Genauer, für Neugierige
Die Konfiguration wird im Kurs gezeigt, aber nicht ausgeführt: Sie braucht GitHub. Andere verbreitete Werkzeuge sind GitLab CI, Jenkins und Azure Pipelines. Die Idee und die Schritte sind dieselben: den Code holen, die Sprache einrichten, die Tests laufen lassen, den Bericht speichern.
Die Regel „Rot stoppt das Zusammenführen“ wird getrennt eingerichtet, im Schutz des Hauptzweigs (in GitHub: Branch Protection, Required Checks). Ohne sie ist die CI nur ein Rat.
Probier es aus
Der Weg einer Änderung
Bringe in die richtige Reihenfolge, was vom Schicken der Änderung bis zum Zusammenführen geschieht.
Der Code wird kompiliertDie CI holt den Code auf eine saubere MaschineDie KomponententestsDie Szenarien und die API-TestsDer Bericht: was bestanden hat und was fehlgeschlagen istDas Zusammenführen ist nur erlaubt, wenn alles grün istDer Programmierer schickt die Änderung und öffnet einen Pull Request
Die schnellsten Tests kommen zuerst: Wenn ein Komponententest fehlschlägt, hat es keinen Sinn, auf die langsameren zu warten. Der Bericht kommt vor der Entscheidung, weil die Entscheidung daraus getroffen wird.
Die Idee
Warum ein Test fehlschlägt
Rot in der CI ist ein Signal, kein Urteil. Es gibt vier Ursachen, und jede braucht etwas anderes:
- ein Fehler im Code: Die Änderung hat etwas kaputt gemacht. Der Code wird repariert. Das ist die Regression, für die es die Tests gibt;
- die Regel hat sich geändert: Der Test sagt die alte Anforderung aus. Der Test ändert sich, aber nach der neuen Anforderung, nicht „bis er besteht“;
- ein falscher Test: Der Test prüft etwas, wonach niemand gefragt hat. Der Test wird repariert, und der Autor wird informiert;
- die Umgebung: Die Datenbank ist nicht gestartet, das Netz ist langsam. Such in der Maschine, nicht im Produkt.
Eines ist keine Ursache: „lass ihn noch einmal laufen“. Wenn der Test beim zweiten Versuch ohne Änderung besteht, ist das Problem immer noch da.
Probier es aus
Was bedeutet dieses Rot?
Die Idee
Ein Test, der manchmal fehlschlägt
Hier ist der Test vom Anfang. Ein Gutscheincode gilt bis einschließlich zu seinem letzten Tag. Der Code fragt „welcher Tag ist heute“ in der Zeitzone des Shops, Sofia:
public boolean isValidToday() { LocalDate today = LocalDate.now(ZoneId.of("Europe/Sofia")); return !today.isAfter(lastDay);}public bool IsValidToday(){ var sofia = TimeZoneInfo.FindSystemTimeZoneById("Europe/Sofia"); var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, sofia)); return today <= lastDay;}def is_valid_today(self): today = datetime.now(ZoneInfo("Europe/Sofia")).date() return today <= self.last_dayisValidToday() { const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Sofia" }).format(new Date()); return today <= this.lastDay;}Und der Test legt einen Code an, dessen letzter Tag „heute“ ist, nach der Uhr der Maschine:
@Test@DisplayName("gilt an seinem letzten Tag")void validOnItsLastDay() { PromoCode code = new PromoCode("HERBST10", LocalDate.now()); assertTrue(code.isValidToday());}[Fact(DisplayName = "gilt an seinem letzten Tag")]public void ValidOnItsLastDay(){ var code = new PromoCode("HERBST10", DateOnly.FromDateTime(DateTime.Now)); Assert.True(code.IsValidToday());}def test_valid_on_its_last_day(): code = PromoCode("HERBST10", date.today()) assert code.is_valid_today()test("gilt an seinem letzten Tag", () => { const today = new Date().toISOString().slice(0, 10); const code = new PromoCode("HERBST10", today); assert.equal(code.isValidToday(), true);});Auf einem Laptop in Sofia sind beide „heute“ derselbe Tag, und der Test besteht immer. Der CI-Server steht in der Zeitzone UTC. Zwischen Mitternacht und 2 Uhr (im Sommer 3 Uhr) Sofioter Zeit ist es in Sofia schon morgen, auf dem Server aber noch heute. Der Code sagt: Der letzte Tag ist vorbei. Der Test schlägt genau dann fehl, wenn der nächtliche Build läuft. (In JavaScript nimmt der Test das Datum immer in UTC, deshalb schlägt er zu dieser Stunde auch auf dem Laptop fehl.)
Ein solcher Test heißt instabil (flaky): Er besteht manchmal und schlägt manchmal fehl, ohne Änderung im Code. Die Ursachen sind fast immer etwas, das der Test nicht kontrolliert:
- die Zeit und das Datum, die Zeitzone;
- die Reihenfolge: Der Test hängt von Daten ab, die ein anderer Test hinterlassen hat;
- das Warten: eine feste Pause, die manchmal nicht reicht;
- das Netz und externe Dienste;
- zufällige Daten ohne festen Seed.
Genauer, für Neugierige
Der Code und der Test auf dieser Karte werden gezeigt und kompiliert, aber im Kurs nicht ausgeführt: Ihr Ergebnis hängt vom Moment des Laufs ab, und eine Prüfung mit erwarteter Ausgabe ist nicht möglich. Genau das ist ihr Problem.
Die Idee
Die Korrektur: Der Tag ist eine Eingabe
Der Test kann die Uhr nicht kontrollieren, also wird der Tag zu einem Parameter. Der Code fragt „gilt er an diesem Tag“:
// Der Code gilt bis einschließlich zu seinem letzten Tag.public boolean isValidOn(LocalDate day) { return !day.isAfter(lastDay);}// Der Code gilt bis einschließlich zu seinem letzten Tag.public bool IsValidOn(DateOnly day) => day <= lastDay;def is_valid_on(self, day): """Der Code gilt bis einschließlich zu seinem letzten Tag.""" return day <= self.last_day// Der Code gilt bis einschließlich zu seinem letzten Tag. Tage sind Text der Form "2026-11-30".isValidOn(day) { return day <= this.lastDay;}Der Test setzt bestimmte Daten, nicht „heute“. Und jetzt kann er die Grenze genau prüfen: den letzten Tag und den Tag danach:
@Test@DisplayName("gilt an seinem letzten Tag")void validOnItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30)));}[Fact(DisplayName = "gilt an seinem letzten Tag")]public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))test("gilt an seinem letzten Tag", () => { assert.equal(code.isValidOn("2026-11-30"), true);});Die ganze Datei, ausgeführt. Sie besteht bei jedem Lauf, auf jeder Maschine, zu jeder Stunde:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertTrue;
class PromoCodeTest { private final PromoCode code = new PromoCode("HERBST10", LocalDate.of(2026, 11, 30));
@Test @DisplayName("gilt vor seinem letzten Tag") void validBeforeItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 1))); }
@Test @DisplayName("gilt an seinem letzten Tag") void validOnItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30))); }
@Test @DisplayName("gilt am Tag danach nicht mehr") void notValidTheDayAfter() { assertFalse(code.isValidOn(LocalDate.of(2026, 12, 1))); }}OK gilt am Tag danach nicht mehrOK gilt an seinem letzten TagOK gilt vor seinem letzten TagTests: 3, bestanden: 3, fehlgeschlagen: 0using Xunit;
namespace Shop;
public class PromoCodeTest{ private readonly PromoCode code = new("HERBST10", new DateOnly(2026, 11, 30));
[Fact(DisplayName = "gilt vor seinem letzten Tag")] public void ValidBeforeItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 1)));
[Fact(DisplayName = "gilt an seinem letzten Tag")] public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));
[Fact(DisplayName = "gilt am Tag danach nicht mehr")] public void NotValidTheDayAfter() => Assert.False(code.IsValidOn(new DateOnly(2026, 12, 1)));}OK gilt am Tag danach nicht mehrOK gilt an seinem letzten TagOK gilt vor seinem letzten TagTests: 3, bestanden: 3, fehlgeschlagen: 0from datetime import date
from promo_code import PromoCode
code = PromoCode("HERBST10", date(2026, 11, 30))
def test_valid_before_its_last_day(): assert code.is_valid_on(date(2026, 11, 1))
def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))
def test_not_valid_the_day_after(): assert not code.is_valid_on(date(2026, 12, 1))OK test_not_valid_the_day_afterOK test_valid_before_its_last_dayOK test_valid_on_its_last_dayTests: 3, bestanden: 3, fehlgeschlagen: 0import { test } from "node:test";import assert from "node:assert/strict";import { PromoCode } from "./promo-code.js";
const code = new PromoCode("HERBST10", "2026-11-30");
test("gilt vor seinem letzten Tag", () => { assert.equal(code.isValidOn("2026-11-01"), true);});
test("gilt an seinem letzten Tag", () => { assert.equal(code.isValidOn("2026-11-30"), true);});
test("gilt am Tag danach nicht mehr", () => { assert.equal(code.isValidOn("2026-12-01"), false);});OK gilt am Tag danach nicht mehrOK gilt an seinem letzten TagOK gilt vor seinem letzten TagTests: 3, bestanden: 3, fehlgeschlagen: 0Und wenn jemand „vor dem letzten Tag“ statt „bis einschließlich zum letzten Tag“ schreibt, schlägt der Test jedes Mal fehl, nicht manchmal:
// Der Code gilt bis einschließlich zu seinem letzten Tag.public boolean isValidOn(LocalDate day) { return day.isBefore(lastDay);}// Der Code gilt bis einschließlich zu seinem letzten Tag.public bool IsValidOn(DateOnly day) => day < lastDay;def is_valid_on(self, day): """Der Code gilt bis einschließlich zu seinem letzten Tag.""" return day < self.last_day// Der Code gilt bis einschließlich zu seinem letzten Tag. Tage sind Text der Form "2026-11-30".isValidOn(day) { return day < this.lastDay;}package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertTrue;
class PromoCodeTest { private final PromoCode code = new PromoCode("HERBST10", LocalDate.of(2026, 11, 30));
@Test @DisplayName("gilt vor seinem letzten Tag") void validBeforeItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 1))); }
@Test @DisplayName("gilt an seinem letzten Tag") void validOnItsLastDay() { assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30))); }
@Test @DisplayName("gilt am Tag danach nicht mehr") void notValidTheDayAfter() { assertFalse(code.isValidOn(LocalDate.of(2026, 12, 1))); }}FEHLER gilt an seinem letzten Tag: expected: <true> but was: <false>OK gilt am Tag danach nicht mehrOK gilt vor seinem letzten TagTests: 3, bestanden: 2, fehlgeschlagen: 1using Xunit;
namespace Shop;
public class PromoCodeTest{ private readonly PromoCode code = new("HERBST10", new DateOnly(2026, 11, 30));
[Fact(DisplayName = "gilt vor seinem letzten Tag")] public void ValidBeforeItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 1)));
[Fact(DisplayName = "gilt an seinem letzten Tag")] public void ValidOnItsLastDay() => Assert.True(code.IsValidOn(new DateOnly(2026, 11, 30)));
[Fact(DisplayName = "gilt am Tag danach nicht mehr")] public void NotValidTheDayAfter() => Assert.False(code.IsValidOn(new DateOnly(2026, 12, 1)));}FEHLER gilt an seinem letzten Tag: Assert.True() Failure Expected: True Actual: FalseOK gilt am Tag danach nicht mehrOK gilt vor seinem letzten TagTests: 3, bestanden: 2, fehlgeschlagen: 1from datetime import date
from promo_code import PromoCode
code = PromoCode("HERBST10", date(2026, 11, 30))
def test_valid_before_its_last_day(): assert code.is_valid_on(date(2026, 11, 1))
def test_valid_on_its_last_day(): assert code.is_valid_on(date(2026, 11, 30))
def test_not_valid_the_day_after(): assert not code.is_valid_on(date(2026, 12, 1))FEHLER test_valid_on_its_last_day: assert FalseOK test_not_valid_the_day_afterOK test_valid_before_its_last_dayTests: 3, bestanden: 2, fehlgeschlagen: 1import { test } from "node:test";import assert from "node:assert/strict";import { PromoCode } from "./promo-code.js";
const code = new PromoCode("HERBST10", "2026-11-30");
test("gilt vor seinem letzten Tag", () => { assert.equal(code.isValidOn("2026-11-01"), true);});
test("gilt an seinem letzten Tag", () => { assert.equal(code.isValidOn("2026-11-30"), true);});
test("gilt am Tag danach nicht mehr", () => { assert.equal(code.isValidOn("2026-12-01"), false);});FEHLER gilt an seinem letzten Tag: false !== trueOK gilt am Tag danach nicht mehrOK gilt vor seinem letzten TagTests: 3, bestanden: 2, fehlgeschlagen: 1Genauer, für Neugierige
Java und C# haben auch einen Standardweg, die Uhr von außen zu übergeben: java.time.Clock und TimeProvider (ab .NET 8). Der Code fragt dann die Uhr, und der Test gibt ihr eine feste. In Python und JavaScript gibt es Bibliotheken, die die Zeit in einem Test einfrieren (freezegun, die gefälschten Timer der Testwerkzeuge). Der einfachste Weg ist aber der auf dieser Karte: Der Tag ist ein Parameter.
Die Antwort auf „welcher Tag ist heute für den Gutscheincode“ ist eine geschäftliche Entscheidung, keine des Programmierers. Hier wurde sie getroffen: in der Zeitzone des Shops.
Die Idee
Der Bericht
Der Bericht aus der CI ist das Erste, was jemand sieht, wenn etwas rot ist. Ein guter Bericht sagt, ohne jemanden zu fragen:
- welcher Test fehlgeschlagen ist, mit einem Namen, der die Regel aussagt („gilt an seinem letzten Tag“), nicht
test17; - erwartet und tatsächlich: „erwartet: true, tatsächlich: false“;
- bei welcher Änderung: wer was geschickt hat;
- die Geschichte: Schlägt er zum ersten Mal fehl, oder gestern und vorgestern auch.
Die Werkzeuge schreiben den Bericht in einem Format, das die CI versteht (meist JUnit-XML), und die CI zeigt ihn neben der Änderung.
Wenn ein Test manchmal fehlschlägt und die Korrektur Zeit braucht, ist das Ehrliche, ihn von den verpflichtenden Tests abzusondern (Quarantäne), mit einem Fehlerbericht und einer Frist, und die Prüfung von Hand zu machen. Nicht ihn zu löschen und nicht ihn laufen zu lassen, bis er besteht.
Übung
Rot in der CI
1. Ursachen und Korrekturen. Die Fragen gehen um den Gutscheincode und die CI des Shops.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Warum und was automatisieren · Vom Szenario zu den Schritten · Komponententests · API und Benutzeroberfläche
- Geht weiter in: Gutscheincodes: von der Anfrage bis zur Freigabe
- Aufgaben zum Thema: Automatisierung