mitmario.dev

Testbaren Code schreiben

PHP Sandbox 4 Min Lesezeit 3 BeispieleLektion 4 von 6

Manche Funktionen wehren sich gegen jeden Test. Man setzt sich hin, will die erste Behauptung schreiben und merkt: Es geht gar nicht. Die übliche Reaktion ist, den Test aufwendiger zu machen. Die richtige ist fast immer, den Code zu ändern.

Woran es liegt

Eine Funktion, die sich alles selbst holt
<?php

declare(strict_types=1);

$db = new PDO("sqlite:" . __DIR__ . "/notizen.db");
$db->exec("DROP TABLE IF EXISTS notizen");
$db->exec("CREATE TABLE notizen (text TEXT NOT NULL)");
$db->exec("INSERT INTO notizen (text) VALUES ('Milch'), ('Brot'), ('Kaffee')");

// Drei Dinge holt sich diese Funktion selbst, und jedes einzelne davon
// macht sie unpruefbar: die Verbindung, die Uhrzeit und den Weg nach
// draussen.
function bericht(): void
{
    $db = new PDO("sqlite:" . __DIR__ . "/notizen.db");
    $anzahl = (int) $db->query("SELECT count(*) FROM notizen")->fetchColumn();

    echo "Bericht vom ", date("d.m.Y H:i:s"), ": ", $anzahl, " Notizen\n";
}

bericht();

// Eine Sekunde warten, damit der Unterschied sichtbar wird.
sleep(1);

bericht();

Diese Funktion tut drei Dinge, die je für sich schon reichen würden:

  1. Sie baut ihre Datenbankverbindung selbst auf. Ein Test müsste also eine Datenbank haben, sie füllen und hinterher aufräumen. Das ist langsam, es hinterlässt Spuren, und zwei Tests nebeneinander kommen sich in die Quere. Die Spur kannst du sehen: Tipp nach dem Lauf ls ins Terminal, und neben index.php liegt eine notizen.db, die vorher nicht da war.
  2. Sie liest die Uhr. Lass das Beispiel laufen, php index.php im Terminal: Zwischen den beiden Aufrufen liegt absichtlich eine Sekunde, und schon unterscheiden sich die Zeilen. Tipp es gleich noch einmal, dann sind es vier Zeilen mit vier Uhrzeiten. Ein Test kann nicht behaupten, was dort steht, weil es beim nächsten Lauf anders ist.
  3. Sie gibt selbst aus. Das Ergebnis geht direkt nach draußen, statt zurückzukommen. Man kann es nicht vergleichen, weil man es gar nicht in die Hand bekommt.

Diese drei sind kein Zufall, sie sind dieselbe Sache in drei Kleidern: Die Funktion holt sich, was sie braucht, statt es entgegenzunehmen.

Hereingeben statt holen

Dieselbe Funktion mit hereingegebenen Teilen
<?php

declare(strict_types=1);

interface NotizSpeicher
{
    /** @return list<string> */
    public function alle(): array;
}

final class ArraySpeicher implements NotizSpeicher
{
    /** @param list<string> $notizen */
    public function __construct(private readonly array $notizen = [])
    {
    }

    public function alle(): array
    {
        return $this->notizen;
    }
}

// Dieselbe Aufgabe. Die Funktion nimmt ihre Zutaten jetzt entgegen und
// gibt ihr Ergebnis zurueck, statt es auszugeben.
function bericht(NotizSpeicher $speicher, DateTimeImmutable $zeitpunkt): string
{
    $anzahl = count($speicher->alle());

    return "Bericht vom " . $zeitpunkt->format("d.m.Y") . ", Notizen: " . $anzahl;
}

$stichtag = new DateTimeImmutable("2026-03-01");

echo bericht(new ArraySpeicher(["Milch", "Brot", "Kaffee"]), $stichtag), "\n";
echo bericht(new ArraySpeicher(), $stichtag), "\n";
echo bericht(new ArraySpeicher(["Milch"]), new DateTimeImmutable("2026-12-24")), "\n";

Dieselbe Aufgabe, andere Schnittstelle. Der Speicher kommt als Parameter herein, der Zeitpunkt auch, und das Ergebnis kommt als Zeichenkette zurück. Drei Aufrufe, drei vorhersagbare Zeilen.

Der Speicher ist dabei genau das Interface aus dem Abschnitt über Objekte, und jetzt bekommt es seinen Zweck. bericht() nimmt die Zusage NotizSpeicher entgegen, nicht eine bestimmte Klasse. Im Betrieb steckt dahinter der Speicher, der wirklich in die Datenbank schreibt. Im Test steckt ein ArraySpeicher dahinter, der seine Notizen einfach im Arbeitsspeicher hält. Der Funktion ist der Unterschied nicht anzusehen, und deshalb muss sie auch nicht angepasst werden.

Bei der Uhr ist es dieselbe Bewegung, nur kleiner: Ein Zeitpunkt ist ein Wert wie jeder andere. Wer date() mitten im Rumpf aufruft, hat sich eine Abhängigkeit von der Wanduhr eingebaut, die man von außen nicht ändern kann. Wer den Zeitpunkt als Parameter nimmt, kann im Test den 24. Dezember hereingeben und muss dafür nicht bis Weihnachten warten.

Wo kommt der echte Zeitpunkt dann her? Von ganz oben, aus der Stelle, die die Anfrage entgegennimmt. Dort steht dann ein einziges new DateTimeImmutable(), und alles darunter rechnet mit einem Wert.

Und jetzt der Test

Und jetzt der Test dazu
<?php

declare(strict_types=1);

use App\ArraySpeicher;
use App\Zaehler;
use PHPUnit\Framework\TestCase;

final class ZaehlerTest extends TestCase
{
    public function testNenntAnzahlUndLetzteNotiz(): void
    {
        $zaehler = new Zaehler(new ArraySpeicher(["Milch", "Brot", "Kaffee"]));

        $this->assertSame("Notizen: 3, zuletzt: Kaffee", $zaehler->kurzfassung());
    }

    public function testSagtEtwasBeiLeeremSpeicher(): void
    {
        $zaehler = new Zaehler(new ArraySpeicher());

        $this->assertSame("Noch keine Notizen", $zaehler->kurzfassung());
    }
}

Hier ist dieselbe Idee als Klasse, so wie sie in einem Projekt aussieht: Zaehler bekommt seinen Speicher im Konstruktor, merkt ihn sich und benutzt ihn. Im Test steht in derselben Zeile ein ArraySpeicher mit drei erfundenen Notizen.

Sieh dir an, wie wenig der Test tut. Kein Aufbau, kein Aufräumen, keine Datei, keine Datenbank. Zwei Zeilen je Fall, und er läuft in Millisekunden. Genau daran erkennt man, dass der Schnitt passt.

Für den Speicher im Test gibt es viele Namen: Attrappe, Test-Double, Fake. Der Name ist egal, das Prinzip ist es nicht: Er ist eine echte Umsetzung des Interfaces, nur eine sehr einfache. Du brauchst dafür kein weiteres Werkzeug. Im Reiter Debug siehst du es von innen: Hol src/Zaehler.php im Editor nach vorn und drück auf Aufzeichnen. In $this->speicher steht dann der ArraySpeicher samt seinen drei erfundenen Notizen, beim zweiten Test ein leerer. Die Klasse hat bekommen, was der Test ihr gegeben hat, und ihr ist nicht anzusehen, dass es keine Datenbank war.

Warum das den Code auch ohne Tests besser macht

Das ist der Teil, der oft untergeht. Eine Klasse, die ihre Zutaten entgegennimmt, sagt in ihrem Konstruktor, wovon sie abhängt. Man muss sie nicht lesen, um es zu wissen, es steht in der ersten Zeile. Eine Klasse, die sich alles selbst holt, verrät das erst, wenn man alle Methoden durchgegangen ist.

Dazu kommt die Wiederverwendbarkeit. Der Zaehler funktioniert mit jedem Speicher, den es je geben wird, ohne dass jemand ihn anfasst. Und die Umkehrung gilt auch: Wenn dir das Testen schwerfällt, ist das ein Hinweis auf einen Schnitt, der auch im Betrieb ungünstig ist. Der Test ist an dieser Stelle kein Prüfer, sondern ein Anzeiger.

Übertreiben sollte man es trotzdem nicht. Nicht jede Kleinigkeit muss durch ein Interface. Die Frage ist immer dieselbe: Gibt es einen zweiten Fall, oder könnte es ihn geben? Bei Speichern und Uhren lautet die Antwort fast immer ja. Bei einer Funktion, die zwei Zahlen addiert, lautet sie nein.

Zum Mitnehmen

Wenn ein Test wehtut, ist selten der Test schuld. Fast immer holt sich der Code, was er braucht, statt es entgegenzunehmen: die Verbindung, die Uhrzeit, den Weg nach draußen.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.