ohne fragile Tests
Dateisystem-Operationen gehören zu den am häufigsten untesteten Teilen von PHP-Anwendungen. Wer direkt mit file_get_contents() und file_put_contents() arbeitet, schreibt Code, der entweder gar nicht getestet wird oder Tests hinterlässt, die echte Dateien erzeugen, Reihenfolge-Abhängigkeiten haben und beim Parallelausführen brechen. vfsStream, Stream-Wrapper und Filesystem-Abstraktion lösen das Problem grundlegend.
Inhaltsverzeichnis
- 1. Das Problem mit direkten Dateisystem-Aufrufen
- 2. Filesystem-Abstraktion: das Interface als Grundlage
- 3. vfsStream: virtuelles Dateisystem im Arbeitsspeicher
- 4. vfsStream korrekt aufsetzen und zurücksetzen
- 5. Dateiberechtigungen und Fehlerfälle testen
- 6. Temporäre Verzeichnisse als Alternative zu vfsStream
- 7. Eigene Stream-Wrapper für externe IO
- 8. Magento Driver-API und Filesystem-Pool
- 9. Ansätze für Dateisystem-Tests im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit direkten Dateisystem-Aufrufen
Jede PHP-Klasse, die direkt file_get_contents(), file_put_contents(), mkdir() oder unlink() aufruft, ist von der realen Dateisystemstruktur des Servers abhängig. Das bedeutet: Ein Test dieser Klasse muss entweder echte Verzeichnisse erstellen, echte Dateien anlegen und am Ende alles wieder aufräumen – oder er umgeht den Dateisystem-Code vollständig und testet die Klasse nicht wirklich. Beide Varianten sind problematisch: Die erste erzeugt fragile Tests mit Seiteneffekten, die zweite erhöht die Coverage-Zahl ohne echten Testnutzen.
Das Problem verstärkt sich in CI-Umgebungen mit eingeschränkten Dateisystemrechten, bei paralleler Testausführung oder wenn Tests in Docker-Containern ohne persistente Volumes laufen. Ein Test, der in Verzeichnis /tmp/test-12345/ schreibt, und ein zweiter Test, der dasselbe Verzeichnis nutzt, können sich gegenseitig stören. Ohne explizites Cleanup hinterlassen fehlgeschlagene Tests Dateileichen, die bei der nächsten Ausführung zu unerwarteten Ergebnissen führen.
Das Grundproblem ist architektonischer Natur: Direkter Dateisystem-Zugriff ist eine Infrastruktur-Abhängigkeit, die genauso behandelt werden sollte wie Datenbankzugriff oder HTTP-Calls. Niemand würde einen Unit-Test schreiben, der direkt in die Datenbank schreibt – aber Dateisystem-Zugriff wird oft nicht mit derselben Sorgfalt behandelt. Die Lösung ist dieselbe wie für alle Infrastruktur-Abhängigkeiten: eine Abstraktionsschicht, die im Test durch eine kontrollierte Implementierung ersetzt werden kann.
2. Filesystem-Abstraktion: das Interface als Grundlage
Der erste Schritt zu testbarem Dateisystem-Code ist die Einführung eines Interface, das alle benötigten Dateisystem-Operationen kapselt. Statt file_get_contents() direkt aufzurufen, injiziert die Klasse ein FilesystemInterface über den Konstruktor und ruft dort $this->filesystem->readFile($path) auf. Im Test kann dann eine Mock-Implementierung dieses Interface übergeben werden, die keine echten Dateien berührt.
Magento 2 bringt bereits eine solche Abstraktion mit: die Magento\Framework\Filesystem-Klasse und das zugehörige Driver-System. Wer in Magento-Modulen eigenen Code schreibt, sollte diese Abstraktionen konsequent nutzen statt rohe PHP-Funktionen aufzurufen. Das hat einen weiteren Vorteil: Magento erlaubt es, für verschiedene Dateisystem-Bereiche (pub/, var/, etc.) verschiedene Driver zu konfigurieren – zum Beispiel einen S3-Driver für Media-Dateien. Code, der über das Filesystem-Interface arbeitet, profitiert automatisch von dieser Flexibilität.
<?php
declare(strict_types=1);
namespace Mironsoft\Export\Api;
/**
* Filesystem abstraction for testable file operations.
*/
interface FilesystemInterface
{
/**
* Read file contents as string.
*
* @throws \RuntimeException if file cannot be read
*/
public function readFile(string $path): string;
/**
* Write content to file, creating directories as needed.
*
* @throws \RuntimeException if file cannot be written
*/
public function writeFile(string $path, string $content): void;
/**
* Check whether a file or directory exists.
*/
public function exists(string $path): bool;
/**
* Create a directory recursively with given permissions.
*/
public function createDirectory(string $path, int $mode = 0755): void;
/**
* Delete a file.
*
* @throws \RuntimeException if file cannot be deleted
*/
public function deleteFile(string $path): void;
/**
* List all files in a directory matching optional pattern.
*
* @return list<string>
*/
public function listFiles(string $directory, string $pattern = '*'): array;
}
3. vfsStream: virtuelles Dateisystem im Arbeitsspeicher
vfsStream ist eine PHPUnit-kompatible Bibliothek, die ein virtuelles Dateisystem vollständig im Arbeitsspeicher emuliert. Mit vfsStream::setup('root') wird ein virtuelles Root-Verzeichnis erzeugt, auf das über das URL-Schema vfs://root/ zugegriffen wird. Alle PHP-Dateisystem-Funktionen – file_get_contents, file_put_contents, mkdir, unlink, is_file, is_dir, glob – funktionieren mit vfsStream-Pfaden wie mit echten Pfaden. Nach dem Test ist das virtuelle Dateisystem automatisch verschwunden, ohne dass Cleanup-Code nötig ist.
Die Installation erfolgt über Composer: composer require --dev mikey179/vfsstream. In der Testklasse wird vfsStream in setUp() initialisiert. Man kann die virtuelle Verzeichnisstruktur komplett mit Dateien und Inhalten vorbefüllen, was das Testen von Code erleichtert, der eine bestimmte Verzeichnisstruktur voraussetzt. vfsStream unterstützt auch Dateiberechtigungen, symbolische Links und Streams – fast alles, was das echte Dateisystem bietet, ohne dessen Seiteneffekte.
<?php
declare(strict_types=1);
namespace Mironsoft\Export\Test\Unit\Model;
use Mironsoft\Export\Model\CsvExporter;
use org\bovigo\vfs\vfsStream;
use org\bovigo\vfs\vfsStreamDirectory;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Tests for CsvExporter using vfsStream — no real filesystem access.
*/
#[CoversClass(CsvExporter::class)]
final class CsvExporterTest extends TestCase
{
private vfsStreamDirectory $root;
private CsvExporter $subject;
protected function setUp(): void
{
// Set up virtual filesystem with initial structure
$this->root = vfsStream::setup('export', structure: [
'tmp' => [],
'output' => [],
'config' => [
'fields.json' => '{"columns":["sku","name","price"]}',
],
]);
$this->subject = new CsvExporter(
exportDir: vfsStream::url('export/output'),
tempDir: vfsStream::url('export/tmp'),
);
}
#[Test]
public function writesValidCsvFile(): void
{
$rows = [
['sku' => 'ABC-001', 'name' => 'Widget Pro', 'price' => '19.99'],
['sku' => 'ABC-002', 'name' => 'Widget Basic', 'price' => '9.99'],
];
$this->subject->export($rows, filename: 'products.csv');
$expectedPath = vfsStream::url('export/output/products.csv');
self::assertTrue($this->root->hasChild('output/products.csv'));
self::assertStringContainsString('ABC-001', file_get_contents($expectedPath));
self::assertStringContainsString('Widget Basic', file_get_contents($expectedPath));
}
#[Test]
public function throwsExceptionWhenOutputDirectoryNotWritable(): void
{
// Make output directory not writable
$this->root->getChild('output')?->chmod(0444);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Cannot write to export directory');
$this->subject->export([['sku' => 'X']], filename: 'fail.csv');
}
}
4. vfsStream korrekt aufsetzen und zurücksetzen
Ein häufiger Fehler beim Einsatz von vfsStream ist das Anlegen des virtuellen Dateisystems in setUpBeforeClass() statt in setUp(). setUpBeforeClass() wird einmal pro Testklasse aufgerufen, setUp() vor jedem einzelnen Test. Da vfsStream seinen Zustand zwischen Tests nicht automatisch zurücksetzt, muss das virtuelle Dateisystem in setUp() komplett neu initialisiert werden. Sonst können Tests voneinander abhängen: Ein Test schreibt eine Datei, der nächste findet sie und verhält sich anders als wenn er allein liefe.
Die vfsStream::setup()-Methode akzeptiert ein Array für die initiale Verzeichnisstruktur, was die Lesbarkeit der Tests erhöht. Statt in setUp() viele mkdir- und file_put_contents()-Aufrufe zu schreiben, lässt sich die komplette Ausgangsstruktur als verschachteltes Array deklarieren. Das macht die Testvoraussetzungen auf einen Blick erkennbar und hält den Setup-Code kompakt. Tiefe Verschachtelungen mit vielen Testdateien sollte man in separate Factory-Methoden auslagern, die in setUp() aufgerufen werden.
5. Dateiberechtigungen und Fehlerfälle testen
Einer der größten Vorteile von vfsStream gegenüber echten temporären Verzeichnissen ist die Möglichkeit, Dateiberechtigungen deterministisch zu setzen. Mit chmod(0444) auf einem vfsStream-Verzeichnis lässt sich das Szenario "Verzeichnis nicht beschreibbar" exakt reproduzieren – ohne Root-Rechte, ohne Betriebssystem-Besonderheiten und ohne dass der Test die Rechte wieder zurücksetzen muss. Das macht es möglich, die Fehlerbehandlung von Dateisystem-Code gründlich zu testen.
Zu den wichtigsten Fehlerfällen, die mit vfsStream getestet werden sollten, gehören: fehlende Schreibrechte auf Zielverzeichnis, Datei bereits vorhanden und schreibgeschützt, Verzeichnis existiert nicht und muss angelegt werden, unerwartetes Löschen einer Datei zwischen Existenzprüfung und Lesen. Letzteres ist ein klassisches TOCTOU-Problem (Time-of-Check to Time-of-Use) und schwer mit echtem Dateisystem zu reproduzieren. Mit vfsStream lässt sich dieses Szenario durch das manuelle Entfernen der Datei im Mock simulieren.
6. Temporäre Verzeichnisse als Alternative zu vfsStream
vfsStream hat Grenzen: Es emuliert nur PHP-Dateisystem-Funktionen, keine nativen Erweiterungen oder externe Tools. Wenn Code intern exec('tar -czf ...'), Symfony Process oder PECL-Erweiterungen für Dateizugriff nutzt, hilft vfsStream nicht weiter. In diesen Fällen sind echte temporäre Verzeichnisse, sauber verwaltet über sys_get_temp_dir() und tearDown(), die robustere Wahl.
Das Muster für echte temporäre Verzeichnisse: In setUp() ein eindeutiges Verzeichnis mit sys_get_temp_dir() . '/phpunit-' . uniqid() anlegen, in tearDown() rekursiv löschen. PHPUnit bietet seit Version 10 keine eingebaute Hilfe dafür, aber eine kurze private function deleteDirectory(string $path): void in einer Basis-TestCase-Klasse erledigt das zuverlässig. Wichtig: Cleanup in tearDown() schreiben, nicht in Tests selbst – so wird auch bei fehlschlagenden Tests aufgeräumt.
<?php
declare(strict_types=1);
namespace Mironsoft\Archive\Test\Unit;
use PHPUnit\Framework\TestCase;
/**
* Base class for tests requiring real temporary directories.
* Handles creation and cleanup automatically.
*/
abstract class FilesystemTestCase extends TestCase
{
private string $tempDirectory = '';
protected function setUp(): void
{
parent::setUp();
$this->tempDirectory = sys_get_temp_dir()
. DIRECTORY_SEPARATOR
. 'phpunit-' . static::class . '-' . uniqid('', true);
mkdir($this->tempDirectory, 0755, recursive: true);
}
protected function tearDown(): void
{
$this->deleteDirectory($this->tempDirectory);
parent::tearDown();
}
/** Get path within the temporary test directory. */
protected function tempPath(string $relative = ''): string
{
return $this->tempDirectory . ($relative ? DIRECTORY_SEPARATOR . $relative : '');
}
/** Recursively delete a directory and all contents. */
private function deleteDirectory(string $path): void
{
if (!is_dir($path)) {
return;
}
foreach (scandir($path) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$full = $path . DIRECTORY_SEPARATOR . $item;
is_dir($full) ? $this->deleteDirectory($full) : unlink($full);
}
rmdir($path);
}
}
7. Eigene Stream-Wrapper für externe IO
Stream-Wrapper erlauben es, für ein URL-Schema wie s3://, ftp:// oder ein eigenes Schema eine eigene PHP-Klasse zu registrieren, die alle Dateisystem-Operationen auf diesem Schema implementiert. Das ist das Prinzip hinter vfsStream (vfs://) und funktioniert für alle Szenarien, in denen Code mit externem Storage arbeitet. Ein eigener Test-Stream-Wrapper kann in Tests registriert werden, Aufrufe aufzeichnen und konfigurierbare Antworten zurückliefern – ohne echte Netzwerkverbindung.
Für den Produktionscode bedeutet das: Wenn ein Modul Dateien aus einem S3-Bucket liest, sollte es das über ein konfigurierbares URL-Schema tun, nicht über einen direkten S3-SDK-Aufruf in der Mitte der Business-Logik. Mit dem AWS-Stream-Wrapper für PHP (s3://bucket/path) ist der Produktionscode dieselbe Klasse wie bei lokalem Dateizugriff. Im Test registriert man einen Mock-Stream-Wrapper für s3://, der vordefinierte Inhalte zurückgibt. Das ermöglicht vollständig isolierte Tests ohne S3-Verbindung und ohne vfsStream-Einschränkungen bei PECL-Erweiterungen.
8. Magento Driver-API und Filesystem-Pool
Magento 2 hat ein durchdachtes Filesystem-System: Magento\Framework\Filesystem und der zugehörige Magento\Framework\Filesystem\DirectoryList trennen verschiedene Dateisystem-Bereiche sauber voneinander. Über $filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR) bekommt man ein Write-Interface, das alle Schreiboperationen auf den var/-Bereich beschränkt. In Tests kann dieses Interface durch ein Mock ersetzt werden.
Das Driver-System darunter abstrahiert den eigentlichen Dateisystem-Zugriff: Magento\Framework\Filesystem\Driver\File ist die Standard-Implementierung für lokale Dateien. Durch das Ersetzen des Drivers im Test lässt sich das gesamte Dateisystem-Verhalten kontrollieren, ohne vfsStream einzusetzen. Der Schlüssel ist dabei, dass Magento-Module den DirectoryWrite- und DirectoryRead-Interface konsequent über Dependency Injection beziehen statt direkt PHP-Funktionen aufzurufen. Das macht Magento-Code ohne weiteren Aufwand testbar.
9. Ansätze für Dateisystem-Tests im Vergleich
Es gibt mehrere Strategien, um Dateisystem-abhängigen Code in PHPUnit zu testen. Die Wahl hängt von der Art des Codes, den Abhängigkeiten und der Testgeschwindigkeit ab.
| Ansatz | Isolation | Overhead | Einschränkungen |
|---|---|---|---|
| Interface-Mock | Vollständig | Minimal | Nur bei abstrahiertem Code möglich |
| vfsStream | Hoch | Gering (RAM) | Kein Support für native Ext. und exec() |
| Echte Temp-Dirs | Mittel | Gering (SSD) | Cleanup nötig, Parallelisierung prüfen |
| Eigener Stream-Wrapper | Hoch | Mittel | Implementierungsaufwand |
| Kein Dateisystem-Test | Keine | Keiner | Dateisystem-Code bleibt ungetestet |
Für neuen Magento-Modul-Code ist das Interface-Mock die bevorzugte Strategie, da Magento selbst die Filesystem-Abstraktion mitbringt. Für Code, der direkte PHP-Funktionen aufruft und nicht sofort refaktoriert werden kann, ist vfsStream die pragmatische Zwischenlösung. Echte temporäre Verzeichnisse eignen sich für Integrationstests, die das Zusammenspiel mehrerer Komponenten über echte Dateien testen sollen. Der wichtigste Schritt ist immer, Dateisystem-Abhängigkeiten überhaupt sichtbar zu machen – durch Dependency Injection statt globaler Funktionsaufrufe.
Mironsoft
PHPUnit-Tests, Testbarkeit und Refactoring für Magento 2 Module
Dateisystem-Code endlich zuverlässig testen?
Wir analysieren euren Magento-Code auf Testbarkeit, führen Filesystem-Abstraktionen ein und schreiben robuste PHPUnit-Tests mit vfsStream und Interface-Mocks — ohne fragile Tests und ohne Cleanup-Probleme.
Testbarkeits-Audit
Identifikation von untestbaren Filesystem-Abhängigkeiten und Refactoring-Plan für saubere Abstraktion
vfsStream-Setup
vfsStream einrichten, bestehende Tests auf virtuelle Dateisysteme umstellen und Cleanup-Probleme lösen
Interface-Design
Filesystem-Interfaces entwerfen, Magento Driver-API korrekt nutzen und Mock-Infrastruktur aufbauen
10. Zusammenfassung
Dateisystem-Code ist testbar – aber nur dann, wenn er nicht direkt PHP-Dateisystem-Funktionen aufruft, sondern eine Abstraktionsschicht nutzt. Das Interface-Mock ist die bevorzugte Strategie für neuen Code: keine Seiteneffekte, maximale Geschwindigkeit, vollständige Kontrolle im Test. Für vorhandenen Code, der direkte Funktionsaufrufe enthält, bietet vfsStream einen pragmatischen Zwischenweg: ein virtuelles Dateisystem im Arbeitsspeicher, das alle Standard-PHP-Funktionen unterstützt und nach dem Test automatisch verschwindet.
Die wichtigsten Grundregeln: vfsStream immer in setUp() initialisieren, nicht in setUpBeforeClass(). Fehlerfälle wie fehlende Schreibrechte explizit mit chmod() auf vfsStream-Objekten testen. Für Code mit externen Tools oder PECL-Erweiterungen echte temporäre Verzeichnisse mit sauberem Cleanup in tearDown() nutzen. In Magento-Modulen die vorhandene Filesystem-Abstraktion (DirectoryWrite, DirectoryRead) über Dependency Injection beziehen und im Test mocken. Das macht Dateisystem-Code genauso testbar wie pure Business-Logik.
Dateisystem-Tests mit PHPUnit — Das Wichtigste auf einen Blick
Filesystem-Abstraktion
Kein direkter Aufruf von file_get_contents() in Business-Logik. Interface injizieren, im Test mocken — kein echtes Dateisystem nötig.
vfsStream
Virtuelles Dateisystem im RAM. In setUp() initialisieren, Berechtigungen mit chmod() testen, kein Cleanup nötig. Kein Support für native Erweiterungen.
Temporäre Verzeichnisse
sys_get_temp_dir() + uniqid() für eindeutige Pfade. Rekursives Cleanup in tearDown() — läuft auch bei fehlschlagenden Tests.
Magento Driver-API
DirectoryWrite/DirectoryRead über DI beziehen, nicht direkt PHP-Funktionen aufrufen. Driver ist in Tests durch Mock ersetzbar.