Zum Inhalt
wannacode

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:

.github/workflows/tests.yml
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=ui

Die 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.

  1. Der Code wird kompiliert
  2. Die CI holt den Code auf eine saubere Maschine
  3. Die Komponententests
  4. Die Szenarien und die API-Tests
  5. Der Bericht: was bestanden hat und was fehlgeschlagen ist
  6. Das Zusammenführen ist nur erlaubt, wenn alles grün ist
  7. Der Programmierer schickt die Änderung und öffnet einen Pull Request

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?

1. Der Test der kostenlosen Lieferung schlägt nach einer Änderung an der Logik des Gutscheincodes fehl. Die Lieferregel hat sich nicht geändert.
2. Der Inhaber hat entschieden, dass die Schwelle 60 € sein soll. Der Programmierer hat den Code geändert, und der Test für 50,00 € schlägt fehl.
3. Alle API-Tests schlagen mit „Verbindung abgelehnt“ fehl, und gestern bestanden sie. Der Code wurde nicht angefasst.
4. Ein Test schlägt bei jedem zehnten Lauf fehl, ohne Änderung im Code.

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:

src/main/java/shop/PromoCode.java
public boolean isValidToday() {
LocalDate today = LocalDate.now(ZoneId.of("Europe/Sofia"));
return !today.isAfter(lastDay);
}

Und der Test legt einen Code an, dessen letzter Tag „heute“ ist, nach der Uhr der Maschine:

src/test/java/shop/PromoCodeTest.java
@Test
@DisplayName("gilt an seinem letzten Tag")
void validOnItsLastDay() {
PromoCode code = new PromoCode("HERBST10", LocalDate.now());
assertTrue(code.isValidToday());
}

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“:

src/main/java/shop/PromoCode.java
// Der Code gilt bis einschließlich zu seinem letzten Tag.
public boolean isValidOn(LocalDate day) {
return !day.isAfter(lastDay);
}

Der Test setzt bestimmte Daten, nicht „heute“. Und jetzt kann er die Grenze genau prüfen: den letzten Tag und den Tag danach:

src/test/java/shop/PromoCodeTest.java
@Test
@DisplayName("gilt an seinem letzten Tag")
void validOnItsLastDay() {
assertTrue(code.isValidOn(LocalDate.of(2026, 11, 30)));
}

Die ganze Datei, ausgeführt. Sie besteht bei jedem Lauf, auf jeder Maschine, zu jeder Stunde:

src/test/java/shop/PromoCodeTest.java
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)));
}
}
Ausgabe
OK gilt am Tag danach nicht mehr
OK gilt an seinem letzten Tag
OK gilt vor seinem letzten Tag
Tests: 3, bestanden: 3, fehlgeschlagen: 0

Und wenn jemand „vor dem letzten Tag“ statt „bis einschließlich zum letzten Tag“ schreibt, schlägt der Test jedes Mal fehl, nicht manchmal:

src/main/java/shop/PromoCode.java
// Der Code gilt bis einschließlich zu seinem letzten Tag.
public boolean isValidOn(LocalDate day) {
return day.isBefore(lastDay);
}
src/test/java/shop/PromoCodeTest.java
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)));
}
}
Ausgabe
FEHLER gilt an seinem letzten Tag: expected: <true> but was: <false>
OK gilt am Tag danach nicht mehr
OK gilt vor seinem letzten Tag
Tests: 3, bestanden: 2, fehlgeschlagen: 1
Genauer, 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.

1. Der Test „gilt an seinem letzten Tag“ schlägt nur im nächtlichen Build fehl. Welche Frage stellst du zuerst?
2. Wie reparierst du einen Test, der vom heutigen Datum abhängt?
3. Ein Test schlägt manchmal fehl, und die Korrektur dauert Tage. Was ist in der Zwischenzeit das Ehrliche?
4. Test A besteht für sich, schlägt aber fehl, wenn Test B vor ihm läuft. Was ist die wahrscheinlichste Ursache?

Test

Teste dich

1. Was ist Continuous Integration (CI)?
2. Warum lässt die CI die Tests auf einer sauberen Maschine laufen und nicht auf dem Laptop des Programmierers?
3. Warum ist ein Test gefährlich, der manchmal fehlschlägt?
4. Was sollte der Bericht über einen fehlgeschlagenen Test sagen?

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