Skip to content

The Ultimate Guide To Testing Uploads and Downloads in Laravel

Laravel provides some helpful documentation for testing that your file uploads and downloads work as expected. But their examples test at a shallow level, in the name of test performance and simplicity. The documented upload tests upload no real content; the download tests only check headers or check for the presence of strings. Thorough testing often requires testing at a deeper level - ensuring the file is parsed properly by your backend, or that you are generating a valid file at all. This guide covers most of those common use cases. Note that Pest is used for the examples here, but the approaches should work just as well for PHPUnit.

Testing Your Uploads Using Real Files

When your code does more than simply store an uploaded file as-is—for example, grabbing the first frame from an uploaded video, parsing the manifest of a SCORM module contained in a zip file, and so on—sometimes there is no better alternative than the real thing. In our case, that means creating test fixture files. These will be example files that you can use in your tests. You can keep a variety of files in your project to cover a variety of cases to test.

Start by creating a Fixtures/ folder inside your tests/ directory. Drop in any sample files you want to test, keeping in mind that the simpler and smaller they are, the better, as they will most likely be committed to git or some other source control. Then, in your test code, you can use file_get_contents and UploadedFile::fake() as follows (replacing sample.mp4 with your fixture’s file name):

use App\Models\User;
use Illuminate\Http\UploadedFile;

it('can process a video', function () {
    /** @var string content of fixture file as string */
    $content = file_get_contents(base_path('tests/Fixtures/sample.mp4'));

    $file = UploadedFile::fake()->createWithContent($filename, $content);

    $this
        ->actingAs(User::factory()->create())
        ->post(route('video.process'), [
            'video' => $file,
        ])
        ->assertOk();
});

Testing Your Uploads Using Files Generated on the Fly

Sometimes tests involving uploads deal with a large variety of permutations that it wouldn’t make sense to create individual fixture files for. In that case, it is often possible to generate those files on the fly in the code of your test. For the sake of demonstration, here we will use the example of Laravel Excel (maatwebsite/excel), and its dependency of PhpSpreadsheet to generate an Excel File:

use App\Models\User;
use Illuminate\Support\Facades\Storage;

function excelFileFromArray(array $sourceArray)
{
    // fake() makes all files saved to this disk go to a temporary directory
    // used only for tests
    $fs = Storage::fake('local');

    $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet;
    $sheet = $spreadsheet->getActiveSheet();
    $sheet->fromArray($sourceArray);
    $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
    $writer->save($fs->path('my-spreadsheet.xlsx'));

    /** @var string content of the generated file as a string */
    $content = $fs->get('my-spreadsheet.xlsx');

    return UploadedFile::fake()->createWithContent('my-spreadsheet.xlsx', $content);
}

it('can import users', function () {
    $excelSourceArray = [
        ['Name', 'Email', 'Organizations'],
        ['John Doe', 'john@example.com', '1,2'],
        ['Jane Smith', 'jane@example.com', ''],
    ];

    $this->actingAs(User::factory()->create())
        ->post(route('users.import'),
            ['file' => excelFileFromArray($excelSourceArray)]
        )
        ->assertOk();
});

The key to this approach is that we figure out how to get the library to write a file to disk. Once the file is saved, the process is nearly identical to that of fixtures: first, read in the contents of the file as a string, then pass it to UploadedFile::fake()->createWithContent. The beauty of writing to the disk as the only requirement to succeed is that writing a file to disk is a nearly universal feature of libraries that manipulate files. You can efficiently generate .zip files with ZipArchive, image files with GD, PDFs with FPDF and so on.

Testing Downloads

Downloads can be a bit trickier to test than uploads, as there are many different response types you could encounter. Given you want to test a download from an endpoint like:

$response = $this->actingAs($user)->get(route('templates.download'));

it’s not obvious whether the endpoint’s $response has a method like $response->getContent(), $response->streamedContent(), $response->getFile(), etc. All HTTP test responses are wrapped in Laravel’s Illuminate\Testing\TestResponse class, which provides assertions and other helper methods, but conceals what the original response was - that original response can still be found, stored at $response->baseResponse.

The following helper function can check for the appropriate way to extract the TestResponse contents to a string based on its type:

function getResponseAsString(TestResponse $response): string|false
{
    if (
        $response->baseResponse instanceof \Symfony\Component\HttpFoundation\StreamedResponse ||
        $response->baseResponse instanceof \Symfony\Component\HttpFoundation\StreamedJsonResponse
    ) {
        return $response->streamedContent();
    }

    if ($response->baseResponse instanceof \Symfony\Component\HttpFoundation\BinaryFileResponse) {
        return $response->getFile()->getContent();
    }

    if ($response->baseResponse instanceof \Symfony\Component\HttpFoundation\Response) {
        return $response->getContent();
    }

    return false;
}

Reading over this function’s code, you may notice that it tests for the class of the baseResponse, and handles getting the contents accordingly. If you only find yourself dealing with one response type, feel free to use it as a reference for the methods you can use.

If the libraries you use to check the downloaded file require reading a file on the disk, you can write it to disk with an appropriate filename. See the Zip Files example below for details.

Testing Downloads Example - Zip Files

(The getResponseAsString function is explained in the Testing Downloads section above.)

use App\Models\User;

it('can export user images in zip file', function () {
    $user = User::factory()->someCustomFactoryMethodToAddImages(2)->create();
    $response = $this->actingAs($user)->get(route('user.images-export'));

    $fs = Storage::fake('local');
    $fs->put('zip-response.zip', getResponseAsString($response));

    $zip = new ZipArchive;
    $zip->open($fs->path('zip-response.zip'));

    // test the number of files contained
    expect($zip->numFiles)->toBe(2);

    // Bonus: extract the files and run checks on them.
    $fs->makeDirectory('extracted');
    $zip->extractTo($fs->path('extracted'));
    // ... tests on the extracted files
});

Testing Downloads Example - Excel and CSV Files

For the sake of demonstration, here we will return to the example of Laravel Excel (maatwebsite/excel), and its dependency of PhpSpreadsheet for reading the contents of an Excel File.

use App\Models\User;

it('can export users to XLSX', function () {
    $response = $this->actingAs(User::factory()->create(['name' => 'Bob']))
        ->get(route('users.export'));

    $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx;
    $reader->setReadDataOnly(true);

    // I know for this test that my response type is a BinaryFileResponse, which
    // already has a temporary file saved locally that I can load.
    // If the response type is different, you can use the getResponseAsString()
    // helper and write the contents to a file, as in the Zip example above.
    $spreadsheet = $reader->load($response->getFile()->getPathname());

    expect($spreadsheet->getSheet(0)->getCell('A1')->getValue())->toBe('Name');
    expect($spreadsheet->getSheet(0)->getCell('A2')->getValue())->toBe('Bob');
});

A similar approach could also be used for reading CSV files, using \PhpOffice\PhpSpreadsheet\Reader\Csv for the reader instead.

Troubleshooting

I end up with a bunch of test files littering my storage folder, and it’s hard to find relevant files when debugging.

To avoid saving files from tests in storage, you can fake a storage disk such that all files saved afterwards are stored in a temporary directory that is cleared with each test run. To do so, run the following in either in specific tests where it’s relevant, or in the setUp method of your base TestCase.php file to apply to all tests:

// Replace ‘local’ with whichever disk is having files saved to it.
// Multiple calls may be made in the case of multiple disks.
Storage::fake('local');

Addendum: Laravel’s Built-in Test Utilities

Laravel provides some simple utilities for surface-level, performant tests of uploads and downloads. In many cases, these can be paired with in-depth tests for even more reassurance that your code is working as expected. In other cases, maybe an in-depth test wasn’t the right way to go, as the surface-level behavior confirmed with the simple test basically guarantees the rest of the expected results.

Uploads

UploadedFile::fake()->image() and ->create()

Rather than providing real files via fixtures or generated files, you can create fake, minimal files that appear to be real. When using ->image(), fake images are actually generated with the right headers inside the files, and will therefore be handled nicely throughout any logic in your app that manipulates images. You can similarly pass a $mimeType argument to ->create() to squeeze past any validation rules about file mime types, but your code may crash if it attempts to open those files. At that point, using fixtures (see “Testing Your Uploads Using Real Files“ above) may be your best bet.

Storage::fake('some-disk-name'); ... ->assertExists() and ->assertMissing()

If your upload logic ends up storing a file temporarily or permanently, these methods are a quick way to assert that your file was cleared out or stored as expected by the end of the request.

Downloads

$response->assertDownload()

Internally, this checks the Content-Disposition HTTP header on the response, which all download responses should have. It’s a nice, quick test that you’re getting anything close to the response you expect.

$response->assertStreamed()

If an endpoint depends on streaming for performance reasons, this assertion can help ensure future changes do not accidentally remove it.

Share
Date Posted
Apr 11, 2025
Approximate Reading Time
Written By

Chris Fritz

Chris is a Senior Software Developer at Clevyr. He enjoys creating highly dynamic frontend applications and devising developer-friendly APIs.