@test
assert
PHPUnit · paratest · CI/CD · Datenbankisolation
Parallele Testausführung mit PHPUnit
Tücken, Isolationsprobleme und Lösungen

Parallele PHPUnit-Läufe sparen Zeit – aber sie stellen Anforderungen an Datenbankisolation, Fixture-Design und Konfiguration, die viele Teams erst nach dem ersten schmerzhaften Race Condition treffen. Dieser Artikel zeigt, wie paratest korrekt eingerichtet wird, warum Shared State die häufigste Fehlerquelle ist und wie man Test-Suites wirklich parallel-sicher macht.

15 Min. Lesezeit paratest · Datenbankisolation · Race Conditions · phpunit.xml PHP 8.x · PHPUnit 10/11 · Linux · Docker

1. Warum parallele Tests sinnvoll und riskant zugleich sind

Mit wachsender Codebasis wächst die Laufzeit der Test-Suite. Was bei einem einzelnen Modul noch in wenigen Sekunden abgeschlossen ist, kann bei einer vollständigen Magento-Integrationssuite schnell mehrere Minuten dauern. Parallele Testausführung zielt darauf ab, mehrere Prozesse gleichzeitig zu starten, sodass CPU-Kerne vollständig ausgelastet werden und die Gesamtlaufzeit sinkt. In gut isolierten Unit-Test-Suiten ohne geteilten Zustand lässt sich die Laufzeit linear auf die Anzahl der Kerne skalieren – bei acht Prozessen auf acht Kernen sind achtmal so viele Tests gleichzeitig aktiv.

Der Haken liegt im geteilten Zustand. Parallele Tests teilen sich dieselbe Datenbank, dasselbe Dateisystem, denselben Cache und dieselben globalen PHP-Registrierungen – sofern man keine bewusste Isolation einrichtet. Tests, die einzeln zuverlässig laufen, beginnen bei paralleler Ausführung zu fluktuieren, weil ein Prozess Daten anlegt, die ein anderer zur gleichen Zeit ändert oder löscht. Das Ergebnis sind flaky tests – Tests, die manchmal grün und manchmal rot sind, ohne dass sich der Code geändert hat. Diese scheinbar nicht reproduzierbaren Fehler sind teurer zu debuggen als reguläre Regressionsfehler, weil ihre Ursache im Timing liegt und sich durch einfaches Wiederholen oft nicht reproduzieren lässt.

2. paratest einrichten und konfigurieren

paratest ist das De-facto-Werkzeug für parallele PHPUnit-Läufe in PHP-Projekten. Es verteilt Tests auf mehrere Prozesse, sammelt die Ergebnisse und gibt einen einheitlichen Bericht aus. Die Installation erfolgt als Composer-Dev-Dependency: composer require --dev brianium/paratest. Danach steht vendor/bin/paratest zur Verfügung.

Die grundlegende Konfiguration übergibt paratest eine vorhandene phpunit.xml und fügt nur die Parallelparameter hinzu. Der wichtigste Parameter ist --processes, der die Anzahl gleichzeitiger PHP-Prozesse steuert. Der Standardwert ist die Anzahl der CPU-Kerne; ein guter Ausgangswert für Datenbanktest-Suiten ist zwei bis vier Prozesse, um Datenbankkonkurrenz zu begrenzen. Die Option --runner wählt zwischen einem WrapperRunner (schnell, aber gleicher Prozess-Container) und einem SqliteRunner (für spezielle Isolation). Die Option --log-junit gibt JUnit-XML aus, das CI-Systeme wie GitLab und GitHub Actions direkt konsumieren können.


# Installation von paratest
composer require --dev brianium/paratest

# Basis-Aufruf: 4 parallele Prozesse, eigene phpunit.xml
vendor/bin/paratest \
  --configuration phpunit.xml \
  --processes 4 \
  --runner WrapperRunner \
  --log-junit var/log/tests/junit.xml \
  --colors

# Nur Unit-Tests parallel (keine Datenbankabhängigkeit)
vendor/bin/paratest \
  --configuration phpunit.xml \
  --testsuite unit \
  --processes 8 \
  --runner WrapperRunner

# Mit pcov für Coverage in parallelen Läufen
XDEBUG_MODE=off \
vendor/bin/paratest \
  --configuration phpunit.xml \
  --coverage-clover var/log/tests/coverage.xml \
  --processes 4

Eine häufige Falle: paratest startet mehrere PHP-Prozesse, von denen jeder die vollständige Bootstrap-Sequenz ausführt. Bei Magento-Integrationstests bedeutet das: jeder Prozess initialisiert seinen eigenen Objekt-Manager, lädt die Konfiguration und richtet die Datenbankverbindung ein. Das kostet Zeit und erklärt, warum paratest bei sehr kurzen Tests manchmal langsamer ist als ein sequenzieller Lauf – der Overhead des Bootstrappings überwiegt den Parallelgewinn. Der Sweet Spot für Parallelisierung liegt bei Tests, deren eigentliche Laufzeit mindestens eine Sekunde beträgt.

3. Datenbankisolation: das Kernproblem paralleler Tests

Die häufigste Fehlerquelle bei parallelen PHPUnit-Tests ist die Datenbank. Bei sequenziellen Tests reicht es, vor jedem Test eine Transaktion zu öffnen und sie nach dem Test zurückzurollen – ein Muster, das viele Frameworks als Trait oder Basisklasse anbieten. Bei parallelen Tests teilen sich mehrere Prozesse dieselbe Datenbankverbindung oder dieselben Tabellen. Ein Prozess, der eine Zeile anlegt, und ein anderer, der denselben Primary Key belegen will, erzeugen Constraint-Verletzungen.

Die sauberste Lösung ist eine separate Testdatenbank pro Prozess. paratest stellt über die Umgebungsvariable TEST_TOKEN einen eindeutigen Integer bereit, der für jeden Prozess unterschiedlich ist. Diese Variable kann verwendet werden, um den Datenbanknamen dynamisch zu konstruieren: magento_test_1, magento_test_2 und so weiter. Das Bootstrap-Skript liest TEST_TOKEN aus und konfiguriert die Datenbankverbindung entsprechend. Dieser Ansatz erfordert, dass vor dem Testlauf alle Testdatenbanken existieren und mit dem gleichen Schema bestückt sind – ein Setup-Skript übernimmt das einmalig pro CI-Job.


<?php
// tests/bootstrap-parallel.php
// Reads TEST_TOKEN from paratest to select the right test database

declare(strict_types=1);

$token = (int) ($_ENV['TEST_TOKEN'] ?? getenv('TEST_TOKEN') ?: 0);
$dbName = sprintf('magento_test_%d', $token);

// Override the database name for this process only
putenv(sprintf('DB_NAME=%s', $dbName));
$_ENV['DB_NAME'] = $dbName;

// Standard Magento bootstrap
require_once __DIR__ . '/../src/app/bootstrap.php';

// Verify the target database exists
$pdo = new PDO(
    sprintf('mysql:host=%s;dbname=%s', $_ENV['DB_HOST'], $dbName),
    $_ENV['DB_USER'],
    $_ENV['DB_PASSWORD']
);
echo sprintf("[paratest bootstrap] Process %d → database: %s\n", $token, $dbName);

Alternativ – und oft einfacher – ist die vollständige Isolation durch Transaktions-Rollback innerhalb eines Prozesses kombiniert mit Test-Suiten-Aufteilung: Unit-Tests laufen mit hoher Parallelität ohne Datenbankzugriff, Integrationstests laufen mit niedriger Parallelität (zwei bis drei Prozesse) und separaten Datenbankinstanzen. Diese hybride Strategie ist praxisnäher als der Versuch, alle Tests maximal zu parallelisieren.

4. Shared State, Dateisystem und globale Singletons

Neben der Datenbank gibt es weitere Quellen für geteilten Zustand, die parallele Tests destabilisieren. Das Dateisystem ist eine der häufigsten: Zwei Tests, die gleichzeitig dieselbe temporäre Datei anlegen oder denselben Cache-Pfad beschreiben, überschreiben sich gegenseitig. Die Lösung ist, in jedem Test individuelle Pfade zu generieren – entweder mit sys_get_temp_dir() . '/' . uniqid('test_', true) oder mit dem TEST_TOKEN-Wert von paratest als Präfix.

Globale PHP-Singletons sind ein tückischeres Problem. Registry-Klassen, statische Caches und globale Variablen werden innerhalb eines Prozesses zwischen Tests geteilt. Wenn paratest den WrapperRunner verwendet, läuft eine Gruppe von Tests im selben PHP-Prozess. Ein Test, der eine statische Variable setzt, hinterlässt diesen Zustand für den nächsten Test in diesem Prozess. Die Lösung: PHPUnit-Testklassen dürfen keine statischen Eigenschaften für Zustand verwenden, und Klassen mit Singleton-Mustern müssen Reset-Methoden anbieten, die im tearDown aufgerufen werden.


<?php
// Singleton with reset method — testable in parallel runs
declare(strict_types=1);

namespace Mironsoft\Cache;

final class InMemoryCache
{
    private static ?self $instance = null;
    private array $store = [];

    private function __construct() {}

    public static function getInstance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    public function get(string $key): mixed
    {
        return $this->store[$key] ?? null;
    }

    public function set(string $key, mixed $value): void
    {
        $this->store[$key] = $value;
    }

    /** Reset singleton state between tests — call in tearDown */
    public static function reset(): void
    {
        self::$instance = null;
    }
}

// In PHPUnit test:
protected function tearDown(): void
{
    InMemoryCache::reset();
    parent::tearDown();
}

5. Fixtures und Factories parallel-sicher gestalten

Fixtures, die mit festen IDs oder festen E-Mail-Adressen arbeiten, sind die häufigste Ursache für Constraint-Verletzungen bei parallelen Tests. Wenn zwei Prozesse gleichzeitig versuchen, einen Kunden mit der E-Mail test@example.com anzulegen, schlägt der zweite INSERT mit einem Duplicate-Key-Fehler fehl. Die Lösung liegt in der Verwendung von eindeutigen Bezeichnern in Fixtures. Factory-Klassen erzeugen eindeutige Werte mit uniqid(), uuid() oder dem TEST_TOKEN-Wert kombiniert mit einem internen Zähler.

Eine Factory-Klasse für parallele Testumgebungen injiziert den Token in alle generierten Werte. Der Primärschlüssel wird aus dem Token und einem atomaren Zähler generiert, sodass selbst bei gleichzeitiger Ausführung keine Kollisionen entstehen. Magento-Fixture-Dateien (_files/) sind per Definition sequenziell und nicht parallel-sicher, wenn sie feste Daten enthalten. Für parallele Integrationstests empfiehlt sich die Migration zu programmatischen Factories, die keine festen Werte enthalten.

6. Race Conditions erkennen und systematisch lösen

Race Conditions in Tests zeigen sich als nicht-deterministische Fehlschläge – Tests, die bei zehn aufeinanderfolgenden Läufen neunmal grün und einmal rot sind. Das Schwierige: Sie treten oft nur bei hoher Last oder bei bestimmten Timing-Konstellationen auf, die lokal schwer zu reproduzieren sind. Das erste Diagnosemittel ist, den Test mehrfach in Folge auszuführen und zu prüfen, ob das Ergebnis stabil ist: for i in {1..10}; do vendor/bin/phpunit --filter TestName; done.

Ein strukturierteres Werkzeug ist das Logging von Datenbankoperationen mit Zeitstempeln. MySQL und MariaDB bieten das General Query Log (general_log = ON), das alle Abfragen mit Zeitstempel protokolliert. Mit diesem Log lässt sich rekonstruieren, welche Prozesse zu welchem Zeitpunkt auf dieselben Tabellen zugegriffen haben. Muster wie gleichzeitige INSERT-Befehle in dieselbe Tabelle mit überlappenden Werten identifizieren Race Conditions eindeutig.


<?php
// Detecting shared state problems — run this helper before each parallel test suite
declare(strict_types=1);

namespace Mironsoft\Tests\Helper;

use PHPUnit\Framework\TestCase;

/**
 * Base class for parallel-safe PHPUnit tests.
 * Generates unique identifiers per process and test.
 */
abstract class ParallelTestCase extends TestCase
{
    private static int $counter = 0;

    /**
     * Returns a unique string identifier safe for parallel runs.
     * Combines process token, timestamp and an internal counter.
     */
    protected function uniqueId(string $prefix = ''): string
    {
        $token = (int) ($_ENV['TEST_TOKEN'] ?? 0);
        self::$counter++;
        return sprintf('%s%d_%d_%d', $prefix, $token, time(), self::$counter);
    }

    /**
     * Returns a unique email safe for DB unique constraints in parallel runs.
     */
    protected function uniqueEmail(): string
    {
        return $this->uniqueId('user_') . '@mironsoft-test.de';
    }

    protected function setUp(): void
    {
        parent::setUp();
        // Verify no global state leaks from previous test
        $this->assertSame(0, ob_get_level(), 'Output buffering leaked from previous test');
    }
}

7. Parallele Tests in CI/CD-Pipelines

In CI/CD-Umgebungen bringt parallele Testausführung den größten Nutzen, weil dort Rechenkapazität vorhanden ist, die bei sequenziellen Läufen ungenutzt bleibt. GitHub Actions und GitLab CI unterstützen beide das native Parallelisieren von Jobs über Matrizen – mehrere Jobs laufen gleichzeitig auf separaten VMs oder Containern und teilen sich keine Ressourcen. Das ist die einfachste und zuverlässigste Form der Parallelisierung, weil jeder Job vollständig isoliert ist.

Innerhalb eines einzelnen CI-Jobs kann paratest zusätzlich genutzt werden, um die Tests auf den Kernen des Jobs zu verteilen. Eine typische Strategie: Unit-Tests mit acht Prozessen parallel, Integrationstests in separaten Jobs mit einer gemeinsamen Testdatenbank pro Job. Die Kombination aus Job-Parallelität (Matrixstrategie) und Prozess-Parallelität (paratest) reduziert die Gesamt-Pipeline-Laufzeit auf einen Bruchteil des Ursprungswerts. Wichtig dabei: JUnit-XML-Berichte aller Jobs werden am Ende zusammengeführt, damit das CI-System einen vollständigen Überblick über alle Tests hat.

Strategie Isolationsgrad Setup-Aufwand Laufzeitgewinn
paratest (WrapperRunner) Mittel – gleicher Host Gering Hoch (Unit-Tests)
paratest + DB pro Token Hoch Mittel Hoch (Integration)
CI-Matrixstrategie Vollständig – separate VMs Mittel Sehr hoch
Sequenziell (Referenz) Vollständig Kein Keiner
Matrix + paratest kombiniert Vollständig Hoch Maximal

9. Zusammenfassung

Parallele PHPUnit-Tests mit paratest beschleunigen Test-Suiten erheblich – setzen aber voraus, dass Tests keinen geteilten Zustand hinterlassen. Die drei wichtigsten Maßnahmen für stabile parallele Tests: erstens Datenbankisolation durch separate Datenbanken pro paratest-Token, zweitens eindeutige Bezeichner in allen Fixtures und Factories statt fester Werte, drittens Reset-Methoden für globale Singletons und statischen Zustand in tearDown. Unit-Tests ohne Datenbankzugriff profitieren sofort und ohne weitere Maßnahmen von maximaler Parallelität.

Der praktische Einstieg: Zunächst nur die Unit-Test-Suite mit acht Prozessen parallel laufen lassen und Laufzeit messen. Danach schrittweise Integrationstests mit zwei Prozessen und separaten Testdatenbanken hinzufügen. Race Conditions durch mehrfache Ausführung desselben Tests diagnostizieren und Factories für eindeutige Daten einführen. Die Investition in parallele Test-Infrastruktur amortisiert sich in jedem Projekt, dessen Test-Suite mehr als zwei Minuten Laufzeit hat.

Parallele PHPUnit-Tests — Das Wichtigste auf einen Blick

paratest Grundkonfiguration

--processes 4 --runner WrapperRunner ist der solide Ausgangspunkt. Für Unit-Tests bis zu 8 Prozesse, für Integrationstests 2–3 mit separaten Datenbanken.

Datenbankisolation

TEST_TOKEN aus paratest lesen und im Bootstrap-Skript als Datenbankname verwenden. Separate DB pro Prozess ist die zuverlässigste Isolation.

Unique Fixtures

Factories mit uniqid() oder TEST_TOKEN generieren. Keine festen E-Mails, IDs oder Dateinamen in parallelen Tests verwenden.

Race Conditions debuggen

Test 10-mal wiederholen, General Query Log aktivieren, Shared State in tearDown zurücksetzen. Flaky Tests sind immer ein Zeichen für geteilten Zustand.