logo

How to keep your tests off the real filesystem

When Storage::fake() isn't an option, vfsStream fills the gap

Aaron Saray
Aaron Saray
2026-07-23

Tests that read or write files can be really annoying. I'll admit it, in the past I've skipped a few. I just hate having to think about seeding the files, cleaning them up after, and don't get me started on the pollution left over when a test crashes before it can tear down.

Laravel made this much easier with Storage::fake(). It swaps the disk for a temporary one and handles all of that for you. Nice.

But what about code that never touches the Storage facade? Let's say I have a command that streams a CSV and rewrites it as pipe delimited. It uses raw PHP file functions for performance:

public function handle(): void
{
    $input = fopen(config('imports.csv_input'), 'r');
    $output = fopen(config('imports.csv_output'), 'w');

    while (($row = fgetcsv($input)) !== false) {
        fwrite($output, implode('|', $row) . PHP_EOL);
    }

    fclose($input);
    fclose($output);
}

Storage::fake() does nothing here, because none of these go through the facade. So how do I test this without writing real files?

This is where vfsStream steps in. Under the hood, every one of those file functions is really talking to a stream. vfsStream registers its own vfs:// stream wrapper, a virtual filesystem that lives entirely in memory. Point your code at a vfs:// path and the same native functions read and write to it like a real path, but nothing ever hits your disk.

The nice part is I can seed files into that virtual filesystem right when I set it up:

public function testItConvertsACommaCsvToPipeDelimited(): void
{
    $root = vfsStream::setup('data', null, [
        'input.csv' => "item1,item2\nitem3,item4\n",
    ]);

    config([
        'imports.csv_input' => $root->url() . '/input.csv',
        'imports.csv_output' => $root->url() . '/output.txt',
    ]);

    $this->artisan('convert-csv')->assertSuccessful();

    self::assertSame(
        "item1|item2\nitem3|item4\n",
        $root->getChild('output.txt')->getContent(),
    );
}

No real files, no cleanup, and nothing left behind if the test crashes halfway through.

Here to help,

Aaron

P.S. If you've ever skipped a test because the setup felt like too much work, our testing workshop will change that.

Toss a coin in the jar if you found this helpful.
Want a tip like this in your inbox every weekday? Sign up below 👇🏼
email
No spam. Only real-world advice.