Warum scheitern die meisten Teams an E2E-Tests in CI/CD?
E2E-Tests lokal ausführen kann jeder. Die eigentliche Herausforderung beginnt, wenn diese Tests zuverlässig in einer CI/CD-Pipeline laufen sollen. Timeouts, fehlende Browser-Binaries, Race Conditions beim Server-Start: Das sind die Probleme, die Teams dazu bringen, E2E-Tests wieder aus der Pipeline zu entfernen.
Dieser Artikel zeigt, wie man mit Cypress und GitHub Actions eine E2E-Pipeline aufsetzt, die tatsächlich funktioniert. Keine abstrakten Konzepte, sondern konkreter Code mit einer Next.js-Beispielanwendung.
Was wir am Ende haben:
- Automatische E2E-Tests bei jedem Pull Request
- Request-Interception für schnelle, deterministische Tests
- Video-Aufzeichnung fehlgeschlagener Tests als Build-Artefakt
Inhalt
- Next.js-Anwendung mit Formular und API-Endpunkt
- Cypress installieren und ersten Test schreiben
- Cypress in GitHub integrieren
- GitHub Actions Workflow konfigurieren
- USEO’s Take: Was wir in Produktions-Pipelines gelernt haben
Nützliche Links
Die Beispielanwendung: Next.js mit asynchronem Formular
Für diesen Artikel verwenden wir eine Next.js-Anwendung mit einem Formular, das einen POST-Request an einen API-Endpunkt sendet. Next.js ist hier Mittel zum Zweck: Cypress ist Framework-agnostisch und funktioniert mit jeder Website.
Die zentrale Komponente Form.js:
// src/components/Form.js
import React from 'react'
import styles from '../../styles/Home.module.css'
const sendForm = () =>
fetch('/api/ping', {
method: 'POST',
})
const Form = () => {
const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null)
const [data, setData] = React.useState(false)
const submitHandler = async (e) => {
e.preventDefault()
setData(false)
setLoading(true)
try {
const response = await sendForm()
await response.json()
setData(true)
setLoading(false)
setError(null)
} catch (error) {
setData(false)
setLoading(false)
setError(error)
}
}
return (
<form className={styles.form} onSubmit={submitHandler}>
<label>
First Name
<input type="text" name="first_name" />
</label>
<label>
Last Name
<input type="text" name="last_name" />
</label>
<button type="submit">Submit</button>
{loading && <h2>Loading...</h2>}
{error && <h2>Error :(</h2>}
{data && <h2>Success!</h2>}
</form>
)
}
export default Form
Der Ablauf: Felder ausfüllen, absenden, auf die Server-Antwort warten, Ergebnis anzeigen.
Der zugehörige API-Endpunkt simuliert eine langsame Antwort (3 Sekunden Delay):
// pages/api/ping.js
export default (req, res) => {
setTimeout(() => {
res.status(200).json({})
}, 3000);
}
Das reicht, um die relevanten Testszenarien abzudecken: asynchrones Verhalten, Loading-State und Fehlerbehandlung.
Cypress installieren und den ersten Test schreiben
yarn add cypress --dev
# oder
npm install cypress --save-dev
Nach der Installation generiert npx cypress open die Ordnerstruktur mit Beispiel-Tests unter cypress/integration/examples. Diese Beispiele sind eine gute Referenz und sollten nicht sofort gelöscht werden.
Der erste Test
// cypress/integration/form.spec.js
describe('The form', () => {
it('shows success message when submitted correctly', () => {
cy.visit("http://localhost:3000");
cy.get('[name="first_name"]').type("Adrian");
cy.get('[name="last_name"]').type("Pilarczyk");
cy.get('[type="submit"').click();
cy.get('form').should('contain', 'Success!')
});
});
Die Struktur folgt einem klaren Muster, das Cypress konsequent durchzieht:
// cypress/integration/form.spec.js
describe('The form', () => {
it('shows success message when submitted correctly', () => {
// Befehl: Seite aufrufen
cy.visit("http://localhost:3000");
// Befehl: Element selektieren und Text eingeben
cy.get('[name="first_name"]')
.type("Adrian");
cy.get('[name="last_name"]').type("Pilarczyk");
// Befehl: Element finden und klicken
cy.get('[type="submit"').click();
// Assertion: Prüfen, ob das Formular "Success!" enthält
cy.get('form')
.should('contain', 'Success!')
});
});
Die Unterscheidung zwischen Befehlen und Assertions ist zentral: Zuerst ein Element selektieren, dann entweder eine Aktion ausführen oder eine Bedingung prüfen. Das ist die gesamte Logik hinter Cypress-Tests.
Beachtenswert an der letzten Zeile:
(...).should('contain', 'Success!')
Kein expliziter Inhaltstyp nötig. Cypress prüft einfach, ob der String irgendwo im Element vorkommt. Das KISS-Prinzip zieht sich durch die gesamte API.
Das Problem mit echten API-Calls in Tests
Dieser Test sendet eine echte Anfrage an den Endpunkt. Das ist realistisch, bringt aber Probleme mit sich:
- Datenbank-Bereinigung: Wer räumt nach den Tests auf?
- Zeitabhängigkeit: Der 3-Sekunden-Delay des Servers macht Tests langsam
- Flakiness: Netzwerk-Timeouts in CI-Umgebungen sind häufig
Die Lösung: cy.intercept() fängt Requests ab und gibt definierte Antworten zurück.
describe('The form', () => {
it('shows success message when submitted correctly', () => {
cy.intercept('POST', '**/api/ping', {
statusCode: 200,
body: {},
})
cy.visit('http://localhost:3000')
cy.get('[name="first_name"]').type('Adrian')
cy.get('[name="last_name"]').type('Pilarczyk')
cy.get('[type="submit"').click()
cy.get('form').should('contain', 'Success!')
})
})
Ergebnis: Der Test läuft in unter 1 Sekunde statt 3+ Sekunden. Und er ist deterministisch, weil keine externe Abhängigkeit mehr besteht.
Cypress in GitHub integrieren
Bevor wir die Pipeline konfigurieren, braucht Cypress eine Verbindung zum Repository. Das läuft über den Cypress Dashboard Service:
- GitHub-App autorisieren
- Projekt im Dashboard initialisieren
projectIdincypress.jsoneintragen
Nach der Konfiguration erscheint die Cypress-Integration in den GitHub Repository-Einstellungen unter “Integrations”.
GitHub Actions Workflow: Vom ersten YAML bis zur Video-Aufzeichnung
Minimale Pipeline
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
cypress-run:
name: Cypress run
runs-on: ubuntu-16.04
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cypress run
uses: cypress-io/github-action@v2
with:
build: npm run build
start: npm run start
wait-on: http://localhost:3000
Das ist die gesamte Konfiguration. Die cypress-io/github-action übernimmt die Installation, den Browser-Setup und die Testausführung. Der wait-on-Parameter ist entscheidend: Er wartet, bis der Dev-Server unter localhost:3000 erreichbar ist, bevor die Tests starten.
Video-Aufzeichnung bei Fehlern
Vier zusätzliche Zeilen aktivieren die Video-Aufzeichnung als Build-Artefakt:
# .github/workflows/e2e.yml
name: e2e
on: [pull_request]
jobs:
cypress-run:
name: Cypress run
runs-on: ubuntu-16.04
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Cypress run
uses: cypress-io/github-action@v2
with:
build: npm run build
start: npm run start
wait-on: http://localhost:3000
- uses: actions/upload-artifact@v1
if: always() # / if: failure() <- besser für Produktion
with:
name: cypress-videos
path: cypress/videos
if: failure() ist für Produktions-Pipelines die bessere Wahl: Videos werden nur bei fehlgeschlagenen Tests hochgeladen, was Speicherplatz spart.
Weitere Optionen
Das Cypress-Plugin für GitHub Actions bietet noch mehr:
- Browser spezifizieren (Chrome, Firefox, Edge)
- Tests eingrenzen auf bestimmte Spec-Dateien
- Parallele Ausführung für grosse Test-Suites
Kombiniert mit GitHub Actions Events lässt sich ein präziser Workflow definieren: etwa E2E-Tests nur bei Änderungen an src/ triggern oder verschiedene Test-Sets für main und Feature-Branches.
USEO’s Take: Was wir in echten Pipelines gelernt haben
In unseren Rails-Projekten setzen wir Cypress für kritische User Flows ein. Hier sind die wichtigsten Erkenntnisse aus der Praxis:
Flaky Tests sind das grösste Problem. Nicht die Konfiguration, nicht die Laufzeit. In einer unserer Pipelines hatten wir eine Flaky-Rate von ca. 8% bei 120 Cypress-Tests. Die Hauptursachen: implizite Waits auf Animationen und Race Conditions bei Single-Page-App-Navigation. Die Lösung war konsequentes Verwenden von cy.intercept() und expliziten data-testid-Attributen statt CSS-Selektoren.
Pipeline-Zeiten sind real. Eine typische Cypress-Suite mit 40 Tests läuft bei uns in ca. 4-6 Minuten auf GitHub Actions (ubuntu-latest, ein Container). Mit Parallelisierung auf 3 Container kommen wir auf unter 2 Minuten. Der Overhead für Container-Setup und Cypress-Installation liegt bei ca. 90 Sekunden pro Container, was sich erst ab ca. 30+ Tests lohnt.
wait-on allein reicht nicht. In Projekten mit längerer Build-Zeit (Rails Asset-Pipeline + Webpack) haben wir gelernt, dass wait-on mit einem Timeout kombiniert werden muss. Standard sind 60 Sekunden. Bei uns brauchte der erste Build manchmal 120+ Sekunden. Die Lösung: wait-on-timeout: 180 in der Action-Konfiguration.
Cypress in Docker vs. native GitHub Actions Runner. Wir haben beides getestet. Der native Runner mit cypress-io/github-action ist einfacher zu konfigurieren und für die meisten Projekte ausreichend. Docker lohnt sich erst, wenn man identische Umgebungen zwischen lokal und CI garantieren muss, etwa bei Browser-spezifischen Rendering-Tests.
Unser Tipp für Schweizer Entwicklungsteams: Cypress Cloud (ehemals Dashboard) speichert Testdaten auf US-Servern. Wer Compliance-Anforderungen hat, sollte die Video-Artefakte ausschliesslich über GitHub Actions Artifacts verwalten und auf Cypress Cloud verzichten.