Testing CSV File downloads in Laravel with PestPHP

Last updated 5th September, 2023

Chances are if you've worked in web development for long enough, you'll have wound up needing to export some all or of a dataset into a CSV file. Using Laravel and League CSV, this couldn't be simpler. And with PestPHP, testing is a breeze. Here's a quick implementation that I use to solve this problem.

Generating a CSV Export

Before we look at testing, let's build out our feature. First thing we want to do is build a CSV export of the models (or anything else) that you're trying to convert to a file download.

In this example I'll be using a CSV package from The PHP League, which makes converting models and arrays into CSV objects a breeze.

use Illuminate\Support\Collection;
use League\Csv\Writer;

class UserCsvExporter
{
    public function __construct(public Collection $users)
    {
        //
    }

    public function create()
    {
        $csv = Writer::createFromFileObject(new SplTempFileObject());

        // At this point you'll likely want to format your
        // user models, but we'll leave that out for the
        // sake of brevity and use the default model
        $csv->insertOne(array_keys(
			$this->users->first()->getAttributes())
		);
        $csv->insertAll($this->users->toArray());

        return $csv;
	}
}

Here I've made a small exporter class that takes a collection of users and inserts them into a SqlTempFileObject, which League CSV will use to create the file.

Now we have that, we can look at the file download response in the Laravel controller. All we need to do here is create a returned response, using the response() helper in Laravel. Then give the response the correct headers, which tell the consumer what kind of content to expect.

use App\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class UserController
{
    public function export(Request $request): Response
    {
        $service = new UserCsvExporter(User::all());

        $date = now()->toDateString();

        return response($service->create()->toString(), 200, [
            'Content-Encoding' => 'none',
            'Content-Type' => 'text/csv; charset=UTF-8',
            'Content-Disposition' => sprintf(
                'attachment; filename=%s', "user-export-{$date}.csv"
            ),
            'Content-Description' => 'File Transfer',
        ]);
    }
}

Simple right?

This should create a file download response that the consumer can use to download the file.

Testing the File Downloads Correctly

So now that we've got our application outputting the correct data, how do we check that it's doing what we want?

I'm going to use PestPHP to write this test, but this should look fairly familiar if you're using PHPUnit.If you haven't used Pest yet, I highly recommend checking it out.

First, we'll set up our test to seed some user data, to give us something to work with.

use App\Modules\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Sequence;
use Illuminate\Support\Carbon;
use League\Csv\Reader;
use function Pest\Laravel\get;

it('exports a CSV file with the expected data', function () {
    // Seed our database with a few users to give our
    // endpoint something to output
    $users = User::factory()
        ->count(5)
        ->state(new Sequence(
            ['email' => 'example-1@gmail.com'],
            ['email' => 'example-2@gmail.com'],
            ['email' => 'example-3@gmail.com'],
            ['email' => 'example-4@gmail.com'],
            ['email' => 'example-5@gmail.com'],
        ))
        ->create();
});

Next, we'll fire off the request to our application and start making some assertions against the response.

At this point, we'll also use Carbon::setTestNow() to lock the datetime in our test to a specific day. This is an great utility that makes testing with time based logic a breeze.

We're going to assert a few things here:

  1. That our content-disposition is "attachment". This tells the browser how to handle the response, in our case - to download the response as a file.
  2. That the content-type is "text/csv". This indicates to the browser that the download contains CSV data content (no surprises here...)
  3. That we haven't compressed our content. Telling the browser that we've compressed our content will on matter if you've actually done it, but it's good practice to include it (and compress your content!)
use App\Modules\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Sequence;
use Illuminate\Support\Carbon;
use League\Csv\Reader;
use function Pest\Laravel\get;

it('exports a CSV file with the expected data', function () {
    $users = User::factory()
        ->count(5)
        ->state(new Sequence(
            // ...
        ))
        ->create();

    // We'll set the time to now - making sure that we get a
    // consistent output on the filename no matter what the date
    Carbon::setTestNow(Carbon::parse('2022-11-11'));

    $response = get(route('users.export'))
        ->assertOk()
        ->assertHeader(
            'content-disposition',
            'attachment; filename=user-export-2022-11-11.csv'
        )
        ->assertHeader('content-type', 'text/csv; charset=UTF-8')
        ->assertHeader('content-encoding', 'none');
});

Testing the File Contents

Finally, we need to test that our download *actually *contains the content we're expecting it to.

Now we have a use for the sequence of emails we created above.

We can once again use League CSV to make this process easier. We can read the contents of the downloaded file and convert it back into the array that we had in our application.

Once we've done that, we assert that the CSV file has the correct headers for each column. Then loop over each row in the data, and check that our users came out in the correct order, using the index of the row.

use App\Modules\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Sequence;
use Illuminate\Support\Carbon;
use League\Csv\Reader;
use function Pest\Laravel\get;

it('exports a CSV file with the expected data', function () {
    $users = User::factory()
        ->count(5)
        ->state(new Sequence(
            // ...
        ))
        ->create();

    Carbon::setTestNow(Carbon::parse('2022-11-11'));

    $response = get(route('users.export'))
        ->assertOk()
        // ...
  
    // Finally, assert that our file contains the right data
    $reader = Reader::createFromString($response->getContent());
    $reader->setHeaderOffset(0);

    expect($reader)
        ->count()->toEqual($users->count())
        ->getHeader()->toMatchArray([
            'id', 'username', 'email', 'createdAt', 'updatedAt'
        ]);

    foreach ($reader->getRecords() as $index => $record) {
        // Here we can assert that the download has the correct contents
        // for each user. In this case, we're checking the users came
        // back in the correct order.
        expect($record)->email->toEqual(
            'example-' . $index + 1 . '@gmail.com'
        );
    }
});

Done.

Summing Up

From experience, while these exports tend to get more complex in real life, this structure holds up pretty well.

Its a similar pattern when using streamed responses too - League should still be able to read the content of a stream. All you need to do is change your application to return a streamDownload in Laravel, and your test to access that content via the streamedContent() getter.

Hope this helped ✌️

No comments yet…

DevInTheWild.

Login To Add Comments.

Want More Like This?

Subscribe to occasional updates.