PHPUnit: Testing Filesystem and IO Code Without Fragile Tests
AI generated
@test
assert
PHPUnit · vfsStream · IO tests · Stream wrapper · Magento 2
Testing filesystem and IO code
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.

14 min read vfsStream · Stream wrapper · Filesystem interface · PHPUnit 10/11 PHP 8.2+ · Magento 2.4

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.

11. FAQ: Testing filesystem and IO code with PHPUnit

1What is vfsStream?
A virtual filesystem in RAM, emulating all standard PHP file functions via the vfs:// scheme. Automatically gone after the test, no cleanup needed.
2Can vfsStream cover every scenario?
No, no native C extensions, no exec(). For tar, rsync, zip use real temp directories or custom stream wrappers.
3vfsStream in setUp() or setUpBeforeClass()?
Always setUp(). Otherwise later tests see files from earlier tests, creating order dependencies. setUp() gives every test a fresh filesystem.
4Test error cases like a non-writable directory?
$this->root->getChild('dir')?->chmod(0444). No root needed, no OS dependency. Deterministic and reproducible.
5What is the filesystem interface pattern?
Inject the interface via DI, mock it in the test. No side effects, maximum control. The cleanest form of filesystem testing.
6Clean up temporary directories after tests?
Recursive delete in tearDown(), runs even for failing tests. Unique directory per test with sys_get_temp_dir() plus uniqid().
7Use the Magento filesystem API in tests?
Obtain Driver\File or DirectoryWrite via DI, mock it in the test. No direct PHP function call in production code, then everything is testable.
8When do I need my own stream wrapper?
For external storage (S3, FTP) accessed via URL schemes. Register a test wrapper for the scheme in the test, controlled responses without a real connection.
9Run filesystem tests in parallel?
No problem with vfsStream. With temp dirs, yes, if uniqid() paths are used. Fixed paths like /tmp/test/ break during parallel execution.
10Unit test or integration test for filesystem code?
Unit test for an isolated class with a mock or vfsStream. Integration test for the interaction of multiple components over real files in temporary directories.