without fragile tests
Filesystem operations are among the most frequently untested parts of PHP applications. Anyone who calls file_get_contents() and file_put_contents() directly ends up writing code that either doesn't get tested at all, or leaves behind tests that create real files, have order dependencies, and break when run in parallel. vfsStream, stream wrappers and filesystem abstraction solve this problem at its root.
Table of Contents
- 1. The problem with direct filesystem calls
- 2. Filesystem abstraction: the interface as a foundation
- 3. vfsStream: a virtual filesystem in memory
- 4. Setting up and resetting vfsStream correctly
- 5. Testing file permissions and error cases
- 6. Temporary directories as an alternative to vfsStream
- 7. Custom stream wrappers for external IO
- 8. Magento driver API and filesystem pool
- 9. Comparing approaches to filesystem tests
- 10. Summary
- 11. FAQ
1. The problem with direct filesystem calls
Every PHP class that directly calls file_get_contents(), file_put_contents(), mkdir() or unlink() depends on the real filesystem structure of the server. That means a test of this class must either create real directories, create real files and clean everything up again afterward, or it bypasses the filesystem code entirely and doesn't really test the class at all. Both variants are problematic: the first produces fragile tests with side effects, the second inflates the coverage number without providing real test value.
The problem intensifies in CI environments with restricted filesystem permissions, during parallel test execution, or when tests run in Docker containers without persistent volumes. A test that writes to directory /tmp/test-12345/ and a second test that uses the same directory can interfere with each other. Without explicit cleanup, failed tests leave behind file remnants that lead to unexpected results on the next run.
The underlying problem is architectural: direct filesystem access is an infrastructure dependency that should be treated the same way as database access or HTTP calls. Nobody would write a unit test that writes directly to the database, yet filesystem access is often not handled with the same care. The solution is the same as for all infrastructure dependencies: an abstraction layer that can be replaced by a controlled implementation in the test.
2. Filesystem abstraction: the interface as a foundation
The first step toward testable filesystem code is introducing an interface that encapsulates all required filesystem operations. Instead of calling file_get_contents() directly, the class injects a FilesystemInterface through the constructor and calls $this->filesystem->readFile($path) there. In the test, a mock implementation of this interface can then be supplied that never touches real files.
Magento 2 already ships with such an abstraction: the Magento\Framework\Filesystem class and its associated driver system. Anyone writing their own code in Magento modules should consistently use these abstractions instead of calling raw PHP functions. This has an additional benefit: Magento allows different drivers to be configured for different filesystem areas (pub/, var/, etc.), for example an S3 driver for media files. Code that works through the filesystem interface automatically benefits from this flexibility.
<?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: a virtual filesystem in memory
vfsStream is a PHPUnit-compatible library that fully emulates a virtual filesystem in memory. Calling vfsStream::setup('root') creates a virtual root directory that is accessed through the URL scheme vfs://root/. All PHP filesystem functions, file_get_contents, file_put_contents, mkdir, unlink, is_file, is_dir, glob, work with vfsStream paths exactly as they would with real paths. After the test, the virtual filesystem is automatically gone, with no cleanup code needed.
Installation happens through Composer: composer require --dev mikey179/vfsstream. In the test class, vfsStream is initialized in setUp(). You can pre-populate the entire virtual directory structure with files and content, which makes it easier to test code that assumes a particular directory layout. vfsStream also supports file permissions, symbolic links and streams, essentially everything the real filesystem offers, without its side effects.
<?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. Setting up and resetting vfsStream correctly
A common mistake when using vfsStream is creating the virtual filesystem in setUpBeforeClass() instead of setUp(). setUpBeforeClass() is called once per test class, setUp() before every individual test. Since vfsStream does not automatically reset its state between tests, the virtual filesystem must be completely reinitialized in setUp(). Otherwise tests can depend on each other: one test writes a file, the next one finds it and behaves differently than it would if run alone.
The vfsStream::setup() method accepts an array for the initial directory structure, which improves test readability. Instead of writing many mkdir and file_put_contents() calls in setUp(), the entire starting structure can be declared as a nested array. This makes the test preconditions recognizable at a glance and keeps the setup code compact. Deeply nested structures with many test files should be moved into separate factory methods that are called from setUp().
5. Testing file permissions and error cases
One of the biggest advantages of vfsStream over real temporary directories is the ability to set file permissions deterministically. Calling chmod(0444) on a vfsStream directory reproduces the "directory not writable" scenario exactly, without root privileges, without operating system quirks, and without the test having to reset the permissions afterward. This makes it possible to thoroughly test the error handling of filesystem code.
Among the most important error cases that should be tested with vfsStream are: missing write permissions on the target directory, a file that already exists and is read-only, a directory that does not exist and must be created, and an unexpected deletion of a file between the existence check and the read. The last case is a classic TOCTOU problem (time-of-check to time-of-use) and is hard to reproduce with a real filesystem. With vfsStream, this scenario can be simulated by manually removing the file within the mock.
6. Temporary directories as an alternative to vfsStream
vfsStream has limits: it only emulates PHP filesystem functions, not native extensions or external tools. If code internally uses exec('tar -czf ...'), Symfony Process, or PECL extensions for file access, vfsStream won't help. In these cases, real temporary directories, cleanly managed through sys_get_temp_dir() and tearDown(), are the more robust choice.
The pattern for real temporary directories: create a unique directory in setUp() with sys_get_temp_dir() . '/phpunit-' . uniqid(), and delete it recursively in tearDown(). PHPUnit hasn't offered any built-in help for this since version 10, but a short private function deleteDirectory(string $path): void in a base test-case class handles it reliably. Important: write the cleanup in tearDown(), not inside the test itself, so that cleanup happens even for failing tests.
<?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. Custom stream wrappers for external IO
Stream wrappers allow you to register your own PHP class for a URL scheme such as s3://, ftp://, or a custom scheme, implementing all filesystem operations on that scheme. This is the principle behind vfsStream (vfs://) and it works for any scenario in which code interacts with external storage. A custom test stream wrapper can be registered in tests, record calls, and return configurable responses, without a real network connection.
For production code, this means: if a module reads files from an S3 bucket, it should do so through a configurable URL scheme, not through a direct S3 SDK call in the middle of the business logic. With the AWS stream wrapper for PHP (s3://bucket/path), the production code is the same class as for local file access. In the test, you register a mock stream wrapper for s3:// that returns predefined content. This enables fully isolated tests without an S3 connection and without the vfsStream limitations around PECL extensions.
8. Magento driver API and filesystem pool
Magento 2 has a well thought-out filesystem system: Magento\Framework\Filesystem and the associated Magento\Framework\Filesystem\DirectoryList cleanly separate different filesystem areas from one another. Calling $filesystem->getDirectoryWrite(\Magento\Framework\App\Filesystem\DirectoryList::VAR_DIR) gives you a write interface that restricts all write operations to the var/ area. In tests, this interface can be replaced with a mock.
The driver system underneath abstracts the actual filesystem access: Magento\Framework\Filesystem\Driver\File is the standard implementation for local files. By replacing the driver in the test, the entire filesystem behavior can be controlled without using vfsStream. The key is that Magento modules consistently obtain the DirectoryWrite and DirectoryRead interfaces through dependency injection instead of calling PHP functions directly. That makes Magento code testable without any additional effort.
9. Comparing approaches to filesystem tests
There are several strategies for testing filesystem-dependent code in PHPUnit. The choice depends on the type of code, its dependencies, and the required test speed.
| Approach | Isolation | Overhead | Limitations |
|---|---|---|---|
| Interface mock | Complete | Minimal | Only possible for abstracted code |
| vfsStream | High | Low (RAM) | No support for native extensions and exec() |
| Real temp dirs | Medium | Low (SSD) | Cleanup required, check parallelization |
| Custom stream wrapper | High | Medium | Implementation effort |
| No filesystem test | None | None | Filesystem code stays untested |
For new Magento module code, the interface mock is the preferred strategy, since Magento already brings its own filesystem abstraction. For code that calls direct PHP functions and cannot be refactored immediately, vfsStream is the pragmatic intermediate solution. Real temporary directories are suitable for integration tests that need to verify how multiple components interact through real files. The most important step is always to make filesystem dependencies visible at all, through dependency injection rather than global function calls.
Mironsoft
PHPUnit tests, testability and refactoring for Magento 2 modules
Ready to finally test filesystem code reliably?
We analyze your Magento code for testability, introduce filesystem abstractions, and write robust PHPUnit tests with vfsStream and interface mocks, with no fragile tests and no cleanup problems.
Testability audit
Identifying untestable filesystem dependencies and a refactoring plan for clean abstraction
vfsStream setup
Setting up vfsStream, migrating existing tests to virtual filesystems and solving cleanup problems
Interface design
Designing filesystem interfaces, using the Magento driver API correctly and building mock infrastructure
10. Summary
Filesystem code is testable, but only if it doesn't call PHP filesystem functions directly and instead uses an abstraction layer. The interface mock is the preferred strategy for new code: no side effects, maximum speed, complete control in the test. For existing code that contains direct function calls, vfsStream offers a pragmatic middle ground: a virtual filesystem in memory that supports all standard PHP functions and disappears automatically after the test.
The most important ground rules: always initialize vfsStream in setUp(), not in setUpBeforeClass(). Explicitly test error cases such as missing write permissions with chmod() on vfsStream objects. For code with external tools or PECL extensions, use real temporary directories with clean cleanup in tearDown(). In Magento modules, obtain the existing filesystem abstraction (DirectoryWrite, DirectoryRead) through dependency injection and mock it in the test. This makes filesystem code just as testable as pure business logic.
Filesystem tests with PHPUnit, the essentials at a glance
Filesystem abstraction
No direct call to file_get_contents() in business logic. Inject the interface, mock it in the test, no real filesystem needed.
vfsStream
Virtual filesystem in RAM. Initialize in setUp(), test permissions with chmod(), no cleanup needed. No support for native extensions.
Temporary directories
sys_get_temp_dir() plus uniqid() for unique paths. Recursive cleanup in tearDown(), runs even for failing tests.
Magento driver API
Obtain DirectoryWrite/DirectoryRead through DI, not by calling PHP functions directly. The driver can be replaced with a mock in tests.