Das Bild zeigt eine ruhige Meereslandschaft bei Sonnenuntergang oder Sonnenaufgang. Die Wasseroberfläche spiegelt die warmen Farben des Himmels wider, der in Tönen von Orange, Gelb, Blau und Violett leuchtet. Am Himmel sind mehrere Vögel im Flug zu sehen, die für eine friedliche und entspannte Stimmung sorgen. Die Silhouetten der Vögel heben sich deutlich vor dem farbenprächtigen Horizont ab und betonen die Weite und Ruhe der Szenerie. Im Hintergrund ist eine dunkle Landzunge oder Küstenlinie zu erkennen, die das Bild abrundet.

Februar: die Zeit des Jahres, in der ich Artikel schreibe wie „Hilfe! Meine Tests funktionieren nicht mehr." oder „Der Todesstern". In der diesjährigen Ausgabe gebe ich Orientierung, wie du von PHPUnit 8 auf PHPUnit 9 migrierst.

Keine Angst vor Major Versions

Durch die Aneinanderreihung von Nomen ermöglicht die deutsche Sprache die Bildung von praktisch endlos langen Wörtern wie „Hauptversionsnummererhöhungsangst". Mit diesem Wort beschreibt man die Angst vor der Erhöhung der Major-Versionsnummer, zum Beispiel von 8.5 auf 9.0, die viele Softwareprojekte zu haben scheinen.

So lagen zwischen der Veröffentlichung von PHP 5.0 und der Veröffentlichung von PHP 7.0 zum Beispiel elf Jahre. Mozilla hat mit dem Veröffentlichungsprozess für den Firefox-Browser gezeigt, dass es auch anders geht: davon erscheint alle sechs bis acht Wochen eine neue Major Version. Ab diesem Jahr plant Mozilla sogar, alle vier Wochen eine neue Major Version von Firefox zu veröffentlichen. Vor diesem Hintergrund ist die jährliche Erhöhung der Major-Versionsnummern von PHPUnit zahm.

PHPUnit 9, eine neue Major Version des Standard-Testframeworks der PHP-Welt, wurde heute veröffentlicht. Eine neue Major-Versionsnummer wie „9" wird üblicherweise mit einer großen Anzahl neuer Funktionen oder anderen Leistungsverbesserungen assoziiert. Das PHPUnit-Projekt handhabt das jedoch anders: So bringt PHPUnit 9.0 zum Beispiel keine wesentlichen neuen Funktionen. Diese werden erst mit PHPUnit 9.1 et cetera im weiteren Verlauf dieses Jahres kommen.

Eine neue Version von PHPUnit wird alle zwei Monate veröffentlicht, jeweils am ersten Freitag im Februar, April, Juni, August, Oktober und Dezember. Die neue Version im Februar ist eine neue Major Version, die anderen Versionen sind neue Minor Versions. Den Prinzipien von Semantic Versioning folgend bedeutet das, dass die Februar-Version nicht abwärtskompatibel mit den Vorgängerversionen ist und zum Beispiel Funktionen entfernt. Die anderen Versionen hingegen sind mit ihren Vorgängern kompatibel und führen zum Beispiel neue Funktionen ein. Aber warum werden jeden Februar Funktionen aus PHPUnit entfernt oder so verändert, dass es notwendig ist, die eigenen Tests anzupassen?

Warum entfernt PHPUnit Funktionen?

PHPUnit ist fast zwanzig Jahre alt. Das bedeutet einerseits, dass manche Funktionen, die vor fünf, zehn, fünfzehn oder sogar zwanzig Jahren notwendig waren, heute keinen Nutzen mehr haben. In vielen Fällen, wie etwa beim direkten Testen privater Objekteigenschaften, hat alte Funktionalität nicht nur keinen positiven Nutzen mehr, sie hat sogar einen negativen Effekt: Entwicklerinnen und Entwickler denken, dass es eine gute Idee sei, eine bestimmte Funktionalität zu nutzen, nur weil sie in PHPUnit vorhanden ist. Im schlimmsten Fall kann das dazu führen, dass Software unsachgemäß entwickelt wird. Andererseits muss Software wie PHPUnit ständig an Veränderungen im Ökosystem angepasst werden: Neue Versionen von PHP müssen ebenso unterstützt werden wie neue Distributionskanäle. Die Migration einer Codebasis wie der von PHPUnit kann nicht über Nacht von PHP 5 auf PHP 7 durchgeführt werden. Damals erforderte auch die Ablösung von PEAR-Paketen durch die Unterstützung von Composer und PHP Archives (PHARs) viel Zeit und Aufwand. Hinzu kommt, dass die Komplexität des Codes durch Workarounds für Bugs in bestimmten PHP-Versionen und die Unterstützung verschiedener PHP-Versionen in derselben PHPUnit-Versionslinie sowie durch das Hinzufügen neuer Funktionen zunimmt.

Lange Zeit war ich mehr oder weniger der einzige Entwickler von PHPUnit. Weil nur ich in der Lage und bereit war, mit dieser Codebasis zu arbeiten. Das war mein Fehler, denn vor zwanzig Jahren wusste ich nicht, was ich heute über Architektur und Programmierung weiß, und ich konnte die Konsequenzen von Designentscheidungen nicht vorhersehen.

Alberto Brandolini beschreibt eine solche Situation wie folgt:

The dark secret of the Dungeon Master is that he knows every trap in the existing legacy software, because he was the one to leave the traps around. [...] Knowledge, in the form of accidental complexity starts accumulating in the head of the Dungeon Master, and silently grows.

Es tröstet mich ein wenig, dass ich damit nicht allein bin. Erich Gamma und Kent Beck, die ursprünglichen Autoren von JUnit, beschreiben ähnliche Probleme:

The problem is in software design often the consequences of your decisions don't become apparent for years. One of the advantages of having to live with JUnit for 8 years, is now we can look back and see which decisions we made worked nicely and which we would have done differently.

In den letzten Jahren hat sich die Situation der PHPUnit-Codebasis erheblich verbessert. Einerseits tragen die jährlichen Major Versions, die Funktionen aus PHPUnit so entfernen oder verändern, dass Implementierung und Nutzung einfacher werden, sowie die Einstellung des Supports für alte PHP-Versionen dazu bei, dass PHPUnit's Codebasis nun leichter zu verstehen und zu pflegen ist. Andererseits haben regelmäßige Code-Sprints dazu geführt, dass neue Entwicklerinnen und Entwickler zum PHPUnit-Projekt gestoßen sind. Diese unterstützen mich nun bei meiner Arbeit.

Marco Pivetta fasst das wie folgt zusammen:

[PHPUnit] has been very stale for ages, and is now moving much faster thanks to people now willing to invest time in it (thanks to constant rework and cleaning).

Nachdem das „Warum" der jährlichen Änderungen hoffentlich klarer geworden ist, ist es an der Zeit, die Änderungen in PHPUnit 9 genauer zu betrachten.

PHP 7.3 Voraussetzung

PHPUnit 9 erfordert PHP 7.3 (oder neuer). Der aktive Support von PHP 7.2 durch das PHP-Projekt endete am 30. November 2019. Zum heutigen Zeitpunkt sind nur PHP 7.3 und PHP 7.4 offiziell und aktiv unterstützt. PHPUnit 9 wird auf PHP 7.3 und PHP 7.4 unterstützt. Wenn PHP 8 Ende des Jahres veröffentlicht wird, wird diese Version ebenfalls von PHPUnit 9 unterstützt.

Wenn du noch PHP 7.2 verwendest, solltest du jetzt damit beginnen, auf eine aktuelle PHP-Version zu migrieren, idealerweise PHP 7.4. Das PHP-Projekt bietet für PHP 7.2 keine Bugfixes mehr an, und sicherheitskritische Bugs werden nur noch bis zum 30. November 2020 behoben. Als langfristiges Ziel sollte das Aktualisieren der verwendeten PHP-Version eine reguläre Aufgabe sein und kein Sonderprojekt, das nur alle Jubeljahre angepackt wird. Der entsprechende Update-Prozess sollte sich am aktiven Support des PHP-Projekts für aktuelle PHP-Versionen orientieren.

Wenn du PHP 7.3 nicht verwenden kannst oder möchtest, kannst du natürlich auch PHPUnit 9 nicht nutzen. Das ist noch kein größeres Problem, da PHPUnit 8.5, das noch mit PHP 7.2 funktioniert, bis Februar 2021 mit Bugfixes unterstützt wird. Du wirst allerdings Verbesserungen in neueren Versionen von PHP und PHPUnit verpassen.

assertEquals()

assertEquals() ist die am häufigsten verwendete Assertion-Methode. Leider, denn assertSame() ist fast immer die bessere Alternative. Im Laufe der Jahre wurden assertEquals() viele optionale Parameter hinzugefügt. Einige davon konnten nicht gleichzeitig verwendet werden, was immer wieder zu Fehlern in Randfällen der zugrundeliegenden Implementierung führte. Das Hauptproblem bei einer langen Liste optionaler Parameter ist, dass man die ersten vier angeben muss, wenn man zum Beispiel den fünften verwenden möchte. Und wer kann sich schon merken, wozu all diese Parameter gut sind? Ich jedenfalls nicht.

Um das zu beheben, wurden in PHPUnit 7.5 spezialisierte Alternativen eingeführt, und die folgenden optionalen Parameter von assertEquals() wurden in PHPUnit 8 als veraltet markiert:

  • $delta
  • $maxDepth
  • $canonicalize
  • $ignoreCase

Diese Parameter, von denen $maxDepth schon lange keine Wirkung mehr hatte, wurden in PHPUnit 9 nun entfernt.

Der optionale Parameter $delta konnte dazu verwendet werden, zwei numerische Werte miteinander zu vergleichen, sodass sie auch dann als gleich gelten, wenn sie – abgesehen von einem Delta – nur annähernd gleich sind. Zum Beispiel

$this->assertEquals(1.0, 1.1, '', 0.1);

stellt sicher, dass der tatsächliche Wert 1.1 dem erwarteten Wert 1.0 mit einem Delta von 0.1 entspricht. Das lässt sich jetzt einfacher und lesbarer ausdrücken (und muss es auch) mit

$this->assertEqualsWithDelta(1.0, 1.1, 0.1);

Der optionale Parameter $canonicalize konnte dazu verwendet werden, sowohl den erwarteten als auch den tatsächlichen Wert vor dem Vergleich in eine kanonische Form zu bringen. Für Werte vom Typ array wird zum Beispiel eine Sortierung durchgeführt. Da $canonicalize der sechste Parameter war (sowie der vierte optionale Parameter nach den beiden nicht-optionalen Parametern $expected und $actual), musste man das in der Vergangenheit so schreiben:

$this->assertEquals($expected, $actual, '', 0.0, 10, true);

Das lässt sich jetzt einfacher und lesbarer ausdrücken (und muss es auch) mit

$this->assertEqualsCanonicalizing($expected, $actual);

Der optionale Parameter $ignoreCase konnte dazu verwendet werden, beim Vergleich von erwartetem und tatsächlichem Wert Groß- und Kleinschreibung zu ignorieren. In der Vergangenheit konnte eine solche Assertion nur so ausgedrückt werden:

$this->assertEquals($expected, $actual, '', 0.0, 10, false, true);

Das lässt sich jetzt einfacher und lesbarer ausdrücken (und muss es auch) mit

$this->assertEqualsIgnoringCase($expected, $actual);

Der Vollständigkeit halber sei erwähnt, dass alles oben Erklärte auch für assertNotEquals() gilt, die Umkehrung von assertEquals().

Analog zum Entfernen der optionalen Parameter $canonicalize und $ignoreCase aus der API von assertEquals() wurden die entsprechenden optionalen Parameter auch aus der API von assertFileEquals() und assertStringEqualsFile() entfernt. An ihrer Stelle gibt es nun spezialisierte Assertion-Methoden:

  • assertFileEqualsCanonicalizing()
  • assertFileEqualsIgnoringCase()
  • assertStringEqualsFileCanonicalizing()
  • assertStringEqualsFileIgnoringCase()

assertContains()

Im Laufe der Zeit wurden auch der Assertion-Methode assertContains() optionale Parameter hinzugefügt. Außerdem wurde ihr Anwendungsbereich von Arrays und Objekten, die das Iterator-Interface implementieren, auf Strings ausgeweitet. Immer wieder gab es Probleme, die auch aus der Implementierung verschiedener, teils widersprüchlicher Anwendungsfälle in derselben Code-Einheit resultierten.

Um diese Probleme zu beheben, führte PHPUnit 7.5 spezialisierte Alternativen ein, die ausschließlich auf Strings operieren. Die optionalen Parameter $checkForObjectIdentity, $checkForNonObjectIdentity und $ignoreCase wurden in PHPUnit 8 als veraltet markiert. Außerdem wurde auch die Verwendung von assertContains() mit String-Haystacks als veraltet markiert. Die genannten Parameter sowie die Möglichkeit, assertContains() mit String-Haystacks zu verwenden, wurden in PHPUnit 9 nun entfernt.

assertContains() wurde so verändert, dass immer ein typsicherer Vergleich (mit dem ===-Operator) durchgeführt wird. Wenn ein Vergleich gewünscht wird, der nicht typsicher ist (mit dem ==-Operator), sollte die neue Methode assertContainsEquals() verwendet werden. Hier sind einige Beispiele:

  • assertContains('bar', 'barbara', '', false) wird zu assertStringContainsString('bar', 'barbara');
  • assertContains('bar', 'barbara', '', true) wird zu assertStringContainsStringIgnoringCase('bar', 'barbara');
  • assertContains(1, [1], '', false, true, false) wird zu assertContainsEquals(1, [1]);
  • assertContains(1, [1], '', false, true, true) wird zu assertContains(1, [1]);

Der Vollständigkeit halber sei erwähnt, dass alles oben Gesagte auch für assertNotContains() gilt, die Umkehrung von assertContains().

assertInternalType()

Die Assertion-Methode assertInternalType() konnte dazu verwendet werden, sicherzustellen, dass eine Variable einen Wert eines bestimmten, nicht benutzerdefinierten Typs enthält. Hier ist ein Beispiel, das sicherstellt, dass eine Variable ein Array enthält:

$this->assertInternalType('array', $variable);

Ein wichtiger Aspekt der oben gezeigten Assertion versteckt sich in einem Parameter: 'array'. Das ist problematisch. Eine IDE kann zum Beispiel keine Autovervollständigung für 'array' anbieten. Außerdem gibt es keinen Schutz vor Tippfehlern in dem String 'array'. Schließlich ist es nur ein String.

Um dieses Problem zu beheben, wurden in PHPUnit 7.5 spezialisierte Alternativen eingeführt. Diese neuen Assertion-Methoden verstecken wesentliche Informationen nicht mehr in einem String-Parameter, sondern machen ihre Zuständigkeit im Methodennamen explizit:

$this->assertIsArray($variable);

Eine explizitere API bedeutet natürlich, dass es mehr Methoden gibt. Jede dieser Methoden ist jedoch einfacher, da sie genau einen Anwendungsfall implementiert. Das führt zu Code, der leichter zu verstehen ist. Er ist auch leichter zu schreiben, weil die IDE nun Autovervollständigung anbieten kann.

Der Vollständigkeit halber sei erwähnt, dass alles oben Erklärte auch für assertNotInternalType() gilt, die Umkehrung von assertInternalType().

assertArraySubset()

Die Methode assertArraySubset() war eine ständige Quelle von Verwirrung und Frustration, was sich in vielen Tickets im PHPUnit-Issue-Tracker widerspiegelt. Diese Situation entstand, weil ich den ursprünglichen Pull Request nicht gründlich genug geprüft hatte. Ich selbst hatte keinen Verwendungszweck für die vorgeschlagene Assertion-Methode. Auf den ersten Blick sah die Implementierung so aus, als würde sie den Anwendungsfall der Person erfüllen, die die neue Funktionalität vorgeschlagen hatte. Im Laufe der Jahre habe ich wiederholt Pull Requests akzeptiert, von denen ich dachte, dass sie nur Bugs in der assertArraySubset()-Implementierung beheben würden. Einige dieser Pull Requests fügten jedoch neue Funktionalität hinzu, die teilweise mit anderen vorhandenen und unterstützten Anwendungsfällen von assertArraySubset() in Konflikt stand. Das hätte nicht passieren dürfen. Diese zusätzliche Funktionalität konnte jedoch nicht entfernt werden, ohne die Abwärtskompatibilität zu brechen.

Während der Arbeit an PHPUnit 8 kam ich zu der Erkenntnis, dass die Probleme von assertArraySubset() nicht gelöst werden können, ohne die Abwärtskompatibilität zu brechen. So entschied ich mich, assertArraySubset() als veraltet zu markieren und in PHPUnit 9 zu entfernen. Das führt zu einem expliziten und offensichtlichen Bruch der Abwärtskompatibilität. Jede Änderung im Verhalten von assertArraySubset() wäre ebenfalls ein Bruch der Abwärtskompatibilität gewesen, aber ein impliziter und nicht offensichtlicher.

Wenn du die Funktionalität von assertArraySubset() benötigst, kannst du sie durch die Installation einer Erweiterung für PHPUnit von Rafael Dohms wiederherstellen.

Nicht-öffentliche Eigenschaften

Zu große Objekte mit problematischen Abhängigkeiten sind in Legacy-Code häufig anzutreffen. Solche Objekte müssen oft indirekt getestet werden, indem ihr nicht-öffentlicher Zustand berücksichtigt wird. In der Vergangenheit wurden Assertions wie assertAttributeEquals() eingeführt, die auf nicht-öffentlichen Eigenschaften arbeiten. Diese Assertions waren nur zum Testen von Legacy-Code gedacht. Ihre Verwendung wurde nie als Best Practice empfohlen, schon gar nicht für neuen Code. Leider wurden diese Assertions zu oft verwendet, um neuen Code zu testen, der die Testbarkeit vernachlässigte. Es ist ein Fehler, den Inhalt nicht-öffentlicher Eigenschaften testen zu wollen. Anstatt den Zustand eines Objekts testen zu wollen, sollte das Verhalten eines Objekts getestet werden.

Im Laufe der Jahre hat sich gezeigt, dass das Bereitstellen von Assertion-Methoden wie assertAttributeEquals() zu schlechten Testpraktiken geführt hat. Für PHPUnit 8 habe ich mich daher entschieden, diese Assertions als veraltet zu markieren, und in PHPUnit 9 wurden sie nun entfernt.

Nicht-öffentliche Methoden sollten nicht direkt getestet werden, indem ihre Sichtbarkeit mit der Reflection API umgangen wird. Der private Zustand eines Objekts darf in einem Test ebenfalls nicht berücksichtigt werden. Beide Praktiken führen zu einer engen Kopplung des Testcodes an den getesteten Code und damit zum Testen von Implementierungsdetails anstelle der API. Sobald sich die Implementierung ändert, schlägt der Test fehl, weil er sich auf private Implementierungsdetails stützt.

Exceptions erwarten

Lange Zeit war es Best Practice, Exceptions zu testen, indem die erwartete Exception mit der Annotation @expectedException am Test dokumentiert wurde. Dieser Ansatz hat zwei grundlegende Probleme:

  • PHP bietet keine native Unterstützung für Annotationen. Stattdessen werden Annotationen in Code-Kommentaren verwendet. In einem Code-Kommentar können keine unqualifizierten Klassennamen verwendet werden, z. B. Example anstelle von vendor\project\Example, wie es im Code nach dem Import der Klasse in den aktuellen Namespace möglich wäre.
  • Andererseits hat PHPUnit keine andere Wahl, als einen Test als erfolgreich zu werten, wenn die durch die Annotation angegebene Exception zu irgendeinem Zeitpunkt während seiner Ausführung ausgelöst wird. Das ist fast nie das, was man möchte.

Ich bin nun der Meinung, dass die Verwendung von Annotationen zum Erwarten von Exceptions mehr Schaden als Nutzen bringt. Daher habe ich die Verwendung von Annotationen wie @expectedException in PHPUnit 8 als veraltet markiert, und die entsprechende Funktionalität wurde in PHPUnit 9 nun entfernt.

Von vorne anfangen!

Jede Entwicklerin und jeder Entwickler kennt den Wunsch: „Lass uns alles wegwerfen und auf der grünen Wiese neu anfangen!" Dieser Wunsch ist auch mir nicht fremd, und so habe ich im Laufe der Jahre mehrmals darüber nachgedacht, PHPUnit ganz oder in Teilen (zum Beispiel „nur" den Test-Runner) von Grund auf neu zu entwickeln. Das letzte Mal habe ich vor zwei Jahren darüber nachgedacht und meine Gedanken und Ideen in einem Vortrag geteilt.

Bisher habe ich mich immer gegen einen solchen revolutionären Ansatz entschieden. Erstens, weil er eine Menge Arbeit erfordern würde, die in einem Stück erledigt werden müsste. Vor allem aber, weil bei einem solchen Neustart fast unvermeidlich alle Tests, die PHPUnit verwenden, zumindest minimal, wahrscheinlich aber erheblich angepasst werden müssten.

Für mich kommt daher nur ein evolutionärer Ansatz in Frage, bei dem Teile von PHPUnit schrittweise ausgetauscht oder verbessert werden. Während eines Code-Sprints im letzten Jahr entfernte Marco Pivetta zum Beispiel sehr alten und unübersichtlichen Code, der sich um das Parsen der Annotationen in PHPUnit-Tests kümmert. Der neue Code hat weniger Fehler und ist viel leichter zu verstehen und anzupassen. Diese Arbeit ist bereits in PHPUnit 8 eingeflossen. Für PHPUnit 9 wurde nun der Code neu geschrieben, der sich um das Laden der XML-Konfigurationsdatei kümmert. Für 2020 steht die Entwicklung eines event-basierten Erweiterungssystems für den Test-Runner auf der Agenda. Seit dem letzten Code-Sprint wird dieses Thema von Ewout Pieter den Ouden, Andreas Möller und Arne Blankerts bearbeitet. Diese Arbeit wird voraussichtlich in PHPUnit 9.1 oder PHPUnit 9.2 einfließen.

Fazit

Der jährliche Major-Version-Releasezyklus von PHPUnit ist darauf ausgelegt, Entwicklerinnen und Entwicklern ausreichend Zeit zur Vorbereitung zu geben. Funktionalität wird ein Jahr vor ihrer Entfernung als veraltet markiert, wodurch alle, die ihre Tests mit einer aktuellen PHPUnit-Version ausführen, die Möglichkeit haben, die notwendigen Änderungen schrittweise statt auf einmal vorzunehmen.

Die Pflege eines Softwareprojekts darf nicht nur reaktiv sein. Sie muss auch proaktiv sein in dem Sinne, dass Entwicklerinnen und Entwickler sich der Änderungen bewusst sein müssen, die sie in den nächsten zwölf Monaten vornehmen müssen, um unangenehme Überraschungen zu vermeiden, wenn eine neue Major Version der Programmiersprache, des Frameworks, der Bibliothek oder des Werkzeugs veröffentlicht wird, das sie verwenden. Das bedeutet zumindest, Release-Ankündigungen neuer Major Versions sorgfältig zu lesen und die erforderlichen Änderungen für einen bevorstehenden Sprint einzuplanen.