@test
assert
PHPUnit · JUnit XML · CI/CD · GitHub Actions · GitLab CI
PHPUnit Testberichte und JUnit XML in CI sauber ausgeben
von --log-junit bis zum HTML-Coverage-Report

Wer PHPUnit-Ergebnisse nur als Terminalausgabe betrachtet, verschenkt den größten Teil des Mehrwerts automatisierter Tests. JUnit XML, HTML-Coverage, Teamcity-Output und strukturierte Testdauer-Reports machen Test-Ergebnisse für CI-Systeme, Teams und Monitoring auswertbar – über einen einzelnen Build-Lauf hinaus.

18 Min. Lesezeit JUnit XML · HTML-Coverage · GitHub Actions · GitLab CI · Jenkins PHPUnit 10/11 · PHP 8.2–8.4

1. Warum Testberichte mehr sind als Terminal-Output

Die Ausgabe von PHPUnit im Terminal ist für den Entwickler beim lokalen Ausführen nützlich – sie ist grün, rot oder gelb, zeigt fehlschlagende Tests mit Stack-Trace und gibt am Ende eine Zusammenfassung. Für eine CI-Pipeline, ein Dashboard oder einen Team-Report ist die Terminalausgabe hingegen praktisch wertlos. Sie ist nicht maschinenlesbar, nicht persistierbar und nicht vergleichbar über Build-Läufe hinweg.

Der strukturierte Testbericht löst dieses Problem. JUnit XML ist das De-facto-Standardformat für Testergebnisse in CI-Systemen, weil es von GitHub Actions, GitLab CI, Jenkins, CircleCI, Azure DevOps und nahezu allen anderen CI-Plattformen nativ gelesen und als Trendgraph visualisiert werden kann. Ein HTML-Coverage-Report gibt Entwicklern eine klickbare Übersicht, welche Codezeilen durch Tests abgedeckt werden. Ein Testdauer-Report zeigt, welche Tests die Build-Zeit dominieren. Zusammen machen diese Berichte automatisierte Tests zu einem dauerhaften Asset für die Qualitätssicherung im Team – nicht nur zu einem Gating-Mechanismus im Deployment.

Dieser Beitrag zeigt, wie PHPUnit-Berichte für verschiedene CI-Systeme korrekt konfiguriert werden, welche Fallstricke bei JUnit XML in PHP-Projekten auftreten und wie Coverage-Reports mit minimalem Overhead in Pipelines integriert werden.

2. JUnit XML: Format, Konfiguration und Fallstricke

Das JUnit XML Format ist de facto ein inoffizieller Standard: Es gibt keine offizielle Spezifikation, aber nahezu alle CI-Systeme erwarten dasselbe Grundschema mit <testsuites>, <testsuite> und <testcase>-Elementen. PHPUnit erzeugt valides JUnit XML über den --log-junit-Parameter oder über die phpunit.xml-Konfiguration. Die Konfiguration in phpunit.xml ist vorzuziehen, weil sie versioniert, reproduzierbar und unabhängig vom CI-Skript ist.

Ein häufiger Fallstrick: PHPUnit erzeugt JUnit XML mit UTF-8-Kodierung, aber einige CI-Parser tolerieren keine XML-Sonderzeichen in Testnamen. Testklassenamen mit Doppelpunkten, Winkelklammern oder Sonderzeichen in Datenprovider-Argumenten führen dazu, dass der Parser die Datei nicht einlesen kann oder falsche Testnamen anzeigt. Die Lösung besteht darin, Testnamen in Datenprovidern kurz und alphanumerisch zu halten oder explizit zu benennen. PHPUnit 10+ sanitisiert Testnamen vor dem Schreiben der XML-Datei stärker als frühere Versionen, was die Kompatibilität verbessert hat.


<?xml version="1.0" encoding="UTF-8"?>
<!-- phpunit.xml — PHPUnit configuration with JUnit XML and Coverage -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         cacheDirectory=".phpunit.cache"
         executionOrder="depends,defects"
         requireCoverageMetadata="false"
         beStrictAboutCoverageMetadata="false">

  <testsuites>
    <testsuite name="Unit">
      <directory>tests/Unit</directory>
    </testsuite>
    <testsuite name="Integration">
      <directory>tests/Integration</directory>
    </testsuite>
  </testsuites>

  <!-- JUnit XML for CI test result reporting -->
  <logging>
    <junit outputFile="reports/junit.xml"/>
    <testdox-text outputFile="reports/testdox.txt"/>
  </logging>

  <!-- Coverage report — requires Xdebug or PCOV -->
  <coverage>
    <report>
      <html outputDirectory="reports/coverage/html" lowUpperBound="50" highLowerBound="90"/>
      <clover outputFile="reports/coverage/clover.xml"/>
      <cobertura outputFile="reports/coverage/cobertura.xml"/>
      <text outputFile="reports/coverage/coverage.txt" showOnlySummary="true"/>
    </report>
    <include>
      <directory suffix=".php">src</directory>
    </include>
    <exclude>
      <directory>src/DataFixtures</directory>
      <file>src/Kernel.php</file>
    </exclude>
  </coverage>

</phpunit>

Die Konfiguration erzeugt nach jedem Test-Lauf vier verschiedene Reports im Verzeichnis reports/. Dieses Verzeichnis wird in der Regel nicht in das Repository eingecheckt, sondern als CI-Artefakt hochgeladen. Wichtig: Das Verzeichnis muss vor dem PHPUnit-Aufruf existieren, sonst schlägt PHPUnit beim Schreiben der Datei mit einem Fatal Error fehl. Ein mkdir -p reports/coverage im CI-Skript vor dem PHPUnit-Aufruf verhindert diesen Fehler.

3. Coverage-Reports: HTML, Clover und Cobertura

PHPUnit unterstützt mehrere Coverage-Ausgabeformate, die für unterschiedliche Zwecke optimiert sind. HTML ist das einzige Format, das direkt für Menschen lesbar ist: Es erzeugt eine Navigation durch alle Klassen und Methoden mit farbiger Hervorhebung abgedeckter und nicht abgedeckter Zeilen. HTML-Coverage eignet sich für Entwickler, die lokal prüfen wollen, welche Zeilen ihrer neuen Funktion noch nicht durch Tests abgedeckt sind.

Clover XML ist das bevorzugte Maschinenformat für Code-Coverage-Dienste wie Coveralls, Codecov und SonarQube. Es enthält Zähler für Zeilen, Methoden, Klassen und Branches. Cobertura XML ist das Format, das von GitLab CI und Azure DevOps für die eingebaute Coverage-Visualisierung verwendet wird. Text-Coverage ist die kompakteste Zusammenfassung und eignet sich für den direkten Ausdruck in CI-Logs. Die Wahl des Formats hängt davon ab, welche Tools im Stack vorhanden sind – in der Regel werden zwei oder drei Formate parallel erzeugt, um verschiedene Empfänger zu bedienen.

Coverage-Reports sind rechenintensiv: Xdebug im Coverage-Modus kann die Test-Laufzeit um das Fünf- bis Zehnfache verlängern. Die Lösung besteht darin, Coverage-Reports nur in einem separaten CI-Job zu erzeugen, der nicht den kritischen Build-Pfad blockiert. Unit-Tests ohne Coverage laufen auf jedem Commit schnell durch; der Coverage-Job läuft nur auf dem Hauptbranch oder über Nacht.

4. Integration in GitHub Actions

GitHub Actions hat in den letzten Jahren native Unterstützung für Test-Summaries über den $GITHUB_STEP_SUMMARY-Mechanismus eingeführt. Daneben existieren Marketplace-Actions, die JUnit XML lesen und als Pull-Request-Kommentar oder Action-Summary ausgeben. Die einfachste und wartungsärmste Lösung ist die Kombination aus direktem --log-junit und dem offiziellen actions/upload-artifact für die Persistierung.


# .github/workflows/tests.yml
name: PHPUnit Tests

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, xml, ctype, dom
          coverage: none   # no coverage = faster

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Create reports directory
        run: mkdir -p reports/coverage

      - name: Run Unit Tests
        run: vendor/bin/phpunit --testsuite Unit --log-junit reports/junit.xml

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()   # upload even on failure
        with:
          name: phpunit-results
          path: reports/junit.xml
          retention-days: 30

  coverage:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP with Xdebug
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, xml, xdebug
          coverage: xdebug

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Run tests with coverage
        run: |
          mkdir -p reports/coverage
          XDEBUG_MODE=coverage vendor/bin/phpunit \
            --coverage-clover reports/coverage/clover.xml \
            --coverage-html reports/coverage/html

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: reports/coverage/
          retention-days: 14

Das Schlüsseldetail im Workflow ist if: always() beim Upload der JUnit-Datei. Ohne diese Bedingung wird das Artefakt nicht hochgeladen, wenn PHPUnit mit einem Fehlercode beendet – also genau dann, wenn die Ergebnisse am nützlichsten wären. GitHub Actions behandelt einen Upload-Step ohne if: always() als übersprungen, wenn ein vorheriger Step fehlschlug. Der JUnit-Bericht muss aber auch (und gerade) bei fehlschlagenden Tests hochgeladen werden, damit CI-Dashboards die Fehlermuster analysieren können.

5. Integration in GitLab CI und Jenkins

GitLab CI hat native Unterstützung für JUnit XML über das reports: junit-Schlüsselwort in der Artifact-Konfiguration. GitLab liest die Datei automatisch und zeigt die Testergebnisse im Merge-Request-Interface an, inklusive eines Vergleichs mit dem Ziel-Branch. Für Cobertura-Coverage zeigt GitLab inline im Diff, welche Zeilen durch Tests abgedeckt sind – ein sehr effektives Tool für Code-Reviews.


# .gitlab-ci.yml
stages:
  - test
  - coverage

unit-tests:
  stage: test
  image: php:8.4-cli
  before_script:
    - apt-get update -qq && apt-get install -y -qq git unzip
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --no-interaction --prefer-dist
    - mkdir -p reports/coverage
  script:
    - vendor/bin/phpunit --testsuite Unit --log-junit reports/junit.xml
  artifacts:
    when: always     # crucial: upload even on failure
    reports:
      junit: reports/junit.xml
    paths:
      - reports/junit.xml
    expire_in: 30 days

coverage-report:
  stage: coverage
  image: php:8.4-cli
  before_script:
    - pecl install xdebug
    - docker-php-ext-enable xdebug
    - composer install --no-interaction --prefer-dist
    - mkdir -p reports/coverage
  script:
    - XDEBUG_MODE=coverage vendor/bin/phpunit
        --coverage-cobertura reports/coverage/cobertura.xml
        --coverage-html reports/coverage/html
        --coverage-text
  coverage: '/^\s*Lines:\s*\d+.\d+\%/'  # GitLab extracts this for badge
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: reports/coverage/cobertura.xml
    paths:
      - reports/coverage/
    expire_in: 7 days
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Der reguläre Ausdruck nach coverage: extrahiert den Coverage-Prozentsatz aus der Textausgabe von PHPUnit und zeigt ihn als Badge im GitLab-Repository-Interface. Jenkins verwendet das JUnit Plugin, um JUnit XML zu verarbeiten: Nach dem PHPUnit-Step wird junit 'reports/junit.xml' in der Jenkinsfile-Pipeline aufgerufen. Jenkins zeichnet dann automatisch den Test-Trend über alle Builds hinweg auf.

6. Testdauer-Reports und langsame Tests identifizieren

Lange Test-Laufzeiten sind in PHP-Projekten ein häufiges Problem, das ohne Messung unsichtbar bleibt. PHPUnit bietet den Parameter --log-events-verbose-text und --report-useless-tests, um Hinweise auf problematische Tests zu geben. Strukturierter lassen sich Testdauern über JUnit XML auswerten: Das Format enthält in jedem <testcase>-Element ein time-Attribut in Sekunden, das sich mit einfachen Shell-Werkzeugen oder Python auswerten lässt.

Das --order-by=duration-Flag (PHPUnit 10+) führt die langsamsten Tests zuerst aus und macht in der Terminalausgabe sofort sichtbar, welche Tests die Gesamtlaufzeit dominieren. Ein Test, der drei Sekunden dauert, ist in einer Unit-Test-Suite fast immer ein Anzeichen für ein Architekturproblem: echte Datenbankverbindungen, HTTP-Requests ohne Mocking oder schlecht gemockte Services. Die Lösung liegt nicht in der Test-Konfiguration, sondern im Testdesign selbst.

7. Artefakte und Retention richtig konfigurieren

Die Retention-Zeit für Testartefakte ist eine Abwägung zwischen Speicherkosten und Analysetiefe. JUnit XML-Dateien sind klein (wenige Kilobyte bis wenige Megabyte) und können problemlos 90 Tage aufbewahrt werden, um Trendanalysen über mehrere Monate zu ermöglichen. HTML-Coverage-Reports sind deutlich größer (10–100 MB bei größeren Projekten) und sollten kürzer aufbewahrt werden – typischerweise 7–14 Tage.

Für langfristige Trend-Analysen empfiehlt sich das Exportieren der Kerndaten aus dem JUnit XML in eine Zeitreihendatenbank. Einfache Shell-Skripte können aus JUnit XML die Anzahl der Tests, die Fehlerquote und die Gesamtdauer extrahieren und in InfluxDB oder Prometheus schreiben. Grafana-Dashboards visualisieren dann die Entwicklung dieser Metriken über Wochen und Monate – ein deutlich mächtigeres Werkzeug als das eingebaute Trend-Chart der meisten CI-Systeme.

CI-System JUnit XML Coverage-Format Konfiguration
GitHub Actions upload-artifact + Marketplace-Actions Clover via Codecov Action if: always() beim Upload
GitLab CI Nativ via reports: junit Cobertura nativ when: always in artifacts
Jenkins JUnit Plugin (junit Step) Cobertura Plugin Jenkinsfile post-always Block
CircleCI store_test_results Clover via externen Dienst path in test_results_path
Azure DevOps PublishTestResults Task Cobertura nativ testResultsFiles: '**/junit.xml'

8. Typische Fehler bei der CI-Ausgabe

Der häufigste Fehler: Das reports/-Verzeichnis existiert nicht, wenn PHPUnit versucht, die JUnit-Datei zu schreiben. PHPUnit erstellt fehlende Verzeichnisse nicht automatisch und bricht stattdessen mit einem Fatal Error ab. Ein mkdir -p reports/coverage vor dem PHPUnit-Aufruf im CI-Skript ist Pflicht. Der zweite häufige Fehler: Coverage-Reports werden in jedem Build-Job aktiviert, was die gesamte Pipeline stark verlangsamt. Coverage sollte auf einen separaten, optionalen Job beschränkt sein.

Ein dritter Fehler betrifft die XDEBUG_MODE-Umgebungsvariable. Ohne XDEBUG_MODE=coverage erzeugt Xdebug 3.x keine Coverage-Daten, selbst wenn Xdebug installiert und geladen ist. PHPUnit gibt in diesem Fall eine Warnung aus, aber die Coverage-Dateien bleiben leer. Der vierte Fehler: JUnit XML wird im CI-System nicht gefunden, weil der Pfad relativ zum Working Directory falsch ist. Absolute Pfade oder ein explizites cd vor dem PHPUnit-Aufruf verhindern dieses Problem.

9. Output-Formate im Vergleich

PHPUnit bietet verschiedene Ausgabeformate, die für unterschiedliche Empfänger optimiert sind. Die Wahl des richtigen Formats für den jeweiligen Kontext reduziert Komplexität und verbessert die Auswertbarkeit der Testergebnisse im Team.

10. Zusammenfassung

Strukturierte PHPUnit-Testberichte verwandeln automatisierte Tests von einem Gating-Mechanismus in ein dauerhaftes Qualitätsinstrument. JUnit XML ist das universelle Format für CI-Systeme und wird von GitHub Actions, GitLab CI, Jenkins und allen anderen relevanten Plattformen nativ unterstützt. Die Konfiguration gehört in phpunit.xml, nicht in CI-Skripte – so bleibt sie versioniert, reproduzierbar und unabhängig vom CI-System.

Coverage-Reports verlangsamen Tests erheblich und sollten in separate, nicht-blockierende Jobs ausgelagert werden. Das if: always()- bzw. when: always-Pattern beim Artefakt-Upload ist bei Testergebnissen Pflicht – die Datei wird genau dann gebraucht, wenn Tests fehlschlagen. Langfristige Trend-Analysen entstehen durch das Exportieren von Kerndaten aus JUnit XML in Zeitreihendatenbanken und deren Visualisierung in Grafana-Dashboards.

PHPUnit Testberichte und JUnit XML — Das Wichtigste auf einen Blick

JUnit XML Konfiguration

In phpunit.xml unter <logging> konfigurieren. mkdir -p reports/ vor PHPUnit-Aufruf ausführen. Immer if: always() beim CI-Artefakt-Upload setzen.

Coverage in separaten Job

Coverage-Reports kosten Laufzeit. Separater Job nur auf Hauptbranch. XDEBUG_MODE=coverage nicht vergessen. Clover für externe Dienste, Cobertura für GitLab/Azure.

CI-System-Spezifika

GitLab: reports: junit nativ. GitHub: upload-artifact + if: always(). Jenkins: JUnit Plugin. Alle: Artefakt-Pfade testen bevor in Produktion.

Testdauer analysieren

--order-by=duration zeigt langsame Tests zuerst. Zeit-Attribut in JUnit XML maschinell auswerten. Langsame Unit-Tests deuten auf Architekturprobleme hin.

11. FAQ: PHPUnit Testberichte und JUnit XML in CI

1Warum schlägt PHPUnit beim Schreiben der JUnit XML Datei fehl?
PHPUnit erstellt fehlende Verzeichnisse nicht automatisch. Ein mkdir -p reports/coverage vor dem PHPUnit-Aufruf im CI-Skript ist Pflicht. Ohne das Verzeichnis bricht PHPUnit mit einem Fatal Error beim Schreiben der Datei ab.
2Warum zeigt GitHub Actions keine Testergebnisse?
GitHub Actions hat keine native JUnit-Anzeige ohne Marketplace-Actions. Artefakt hochladen mit upload-artifact. Für PR-Anzeige zusätzliche Actions wie dorny/test-reporter verwenden.
3Warum ist die Coverage-Datei leer, obwohl Xdebug installiert ist?
Xdebug 3.x benötigt XDEBUG_MODE=coverage als Umgebungsvariable. Ohne diese Variable ist Coverage deaktiviert, auch wenn Xdebug geladen ist.
4Welches Coverage-Format für GitLab CI?
Cobertura XML für die native GitLab Coverage-Visualisierung im Merge Request. Zusätzlich Clover XML für externe Dienste wie Coveralls oder Codecov. HTML für lokale Entwickler.
5Wie extrahiere ich den Coverage-Prozentsatz für einen GitLab Badge?
Mit coverage: '/^\s*Lines:\s*\d+.\d+\%/' in der .gitlab-ci.yml. GitLab extrahiert den ersten Match aus der Job-Ausgabe automatisch.
6Warum Coverage in separaten CI-Job auslagern?
Xdebug im Coverage-Modus verlangsamt die Laufzeit um das Fünf- bis Zehnfache. Separate Jobs mit eigener Trigger-Bedingung halten den kritischen Build-Pfad schnell.
7Wie finde ich die langsamsten Tests?
PHPUnit 10+ unterstützt --order-by=duration. Alternativ das time-Attribut aus dem JUnit XML mit einem Shell-Skript auswerten und sortieren.
8Was bedeutet if: always() bei GitHub Actions?
Ohne if: always() überspringt GitHub Actions den Upload-Step bei Fehlern. JUnit XML wird aber genau dann gebraucht. if: always() stellt sicher, dass die Datei immer hochgeladen wird.
9Wie lange sollten Test-Artefakte aufbewahrt werden?
JUnit XML: 30–90 Tage. HTML-Coverage: 7–14 Tage. Für langfristige Trends Kerndaten in eine Zeitreihendatenbank exportieren statt große Artefakte aufzubewahren.
10Kann PHPUnit JUnit XML und Coverage gleichzeitig ausgeben?
Ja. In phpunit.xml können unter <logging> und <coverage> mehrere Formate gleichzeitig konfiguriert werden. PHPUnit erzeugt alle konfigurierten Ausgaben in einem Lauf.