Testing Streamed Responses in Laravel

Tutorials

May 1st, 2019

laravel-testing-streamed-response.png

Laravel provides a convenient API to generate an HTTP response that forces the user’s browser to download the file for a given URL. When writing a feature that includes file downloads in your application, Laravel provides a pleasant testing experience to make writing and testing downloadable files a breeze.

Let’s go over a hands-on example of creating and testing a downloadable URL in a Laravel app.

Introduction

In this tutorial, we are going to build a quick users export to CSV feature that allows a user to download a CSV export of all users in the database. To force a user download, the Laravel has a download method that accepts a path to a file (as noted in the response documentation):

1// Download response for a filesystem file
2return response()->download($pathToFile, $name, $headers);

If you are providing an export of customers, users, or another database record the response()->streamDownload() is really awesome:

1return response()->streamDownload(function () {
2 echo GitHub::api('repo')
3 ->contents()
4 ->readme('laravel', 'laravel')['contents'];
5}, 'laravel-readme.md');

This tutorial is a quick demo of how to write and test controllers responding with a streamed file response. I want to note, that in a real application you should provide some security mechanism around exporting users based on your application’s business rules.

Setting Up the Application

First, we need to create a new Laravel application and scaffold the authentication:

1laravel new testing-stream-response
2cd testing-stream-response
3php artisan make:auth

Next, let’s configure a SQLite database for our testing environment. This tutorial will use tests to drive out the feature and you are free to configure any type of database to try out the application in the browser.

Open the phpunit.xml file in the root of the project and add the following environment variables:

1<php>
2 <!-- ... -->
3 <server name="DB_CONNECTION" value="sqlite"/>
4 <server name="DB_DATABASE" value=":memory:"/>
5</php>

We scaffolded a quick Laravel application and generated the authentication files needed to protect our user export route. We’re ready to start testing our export and writing the controller.

Writing the Test

We’ll drive this feature out with a PHPUnit feature test and build the controller logic as we go.

First, we’ll create a new feature test:

1php artisan make:test UsersExportTest

Inside this file, we’ll scaffold out the first test, which is the expectation that guests are redirected to the login URL when trying to access /users/export.

1<?php
2
3namespace Tests\Feature;
4
5use Tests\TestCase;
6use Illuminate\Foundation\Testing\WithFaker;
7use Illuminate\Foundation\Testing\RefreshDatabase;
8
9class UsersExportTest extends TestCase
10{
11 /** @test */
12 public function guests_cannot_download_users_export()
13 {
14 $this->get('/users/export')
15 ->assertStatus(302)
16 ->assertLocation('/login');
17 }
18}

We are requesting the export endpoint and expecting a redirect response to the login page.

We haven’t even defined the route, so this test will fail when we run the test:

1$ phpunit --filter=guests_cannot_download_users_export
2PHPUnit 7.5.9 by Sebastian Bergmann and contributors.
3
4F 1 / 1 (100%)
5
6Time: 113 ms, Memory: 14.00 MB
7
8There was 1 failure:
9
101) Tests\Feature\UsersExportTest::guests_cannot_download_users_export
11Expected status code 302 but received 404.
12Failed asserting that false is true.

We haven’t defined a route, so let’s do so now in the routes/web.php file:

1Route::get('/users/export', 'UsersExportController');

Rerunning the test brings us to the next failure that we need to fix:

1phpunit --filter=guests_cannot_download_users_export
2PHPUnit 7.5.9 by Sebastian Bergmann and contributors.
3
4E 1 / 1 (100%)
5
6Time: 96 ms, Memory: 12.00 MB
7
8There was 1 error:
9
101) Tests\Feature\UsersExportTest::guests_cannot_download_users_export
11UnexpectedValueException: Invalid route action: [App\Http\Controllers\UsersExportController].

Next, generate the Controller as an invokable action. I find that for file exports I tend to reach for an invokable controller because I want to be explicit with the intention of this controller outside my typical RESTful routes.

Before running this command, you need to comment out the route we added to the routes/web.php file:

1// Route::get('/users/export', 'UsersExportController');

Now you can create a new invokable controller with the artisan command:

1php artisan make:controller -i UsersExportController

Uncomment the route we added and rerun the tests to get the next test failure:

1phpunit --filter=guests_cannot_download_users_export
2PHPUnit 7.5.9 by Sebastian Bergmann and contributors.
3
4F 1 / 1 (100%)
5
6Time: 122 ms, Memory: 14.00 MB
7
8There was 1 failure:
9
101) Tests\Feature\UsersExportTest::guests_cannot_download_users_export
11Expected status code 302 but received 200.

The last code change we need to add is the auth middleware to protect the route from guests. We don’t assume any permissions for this tutorial—any user can download the export of users.

1Route::get('/users/export', 'UsersExportController')
2 ->middleware('auth');

Finally, our test should pass:

1phpunit --filter=guests_cannot_download_users_export
2PHPUnit 7.5.9 by Sebastian Bergmann and contributors.
3
4. 1 / 1 (100%)
5
6Time: 118 ms, Memory: 14.00 MB
7
8OK (1 test, 2 assertions)

Starting the Export Feature

We have our user export test and controller in place, and we are ready to start testing the actual streamed download feature. Our first step is creating the next test case in the UsersExportTest test class which requests the export endpoint as an authenticated user.

1// Make sure to import the RefreshDatabase trait
2use RefreshDatabase;
3
4/** @test */
5public function authenticated_users_can_export_all_users()
6{
7 $users = factory('App\User', 5)->create();
8
9 $response = $this->actingAs($users->first())->get('/users/export');
10 $content = $response->streamedContent();
11 dd($content);
12}

Notice the streamedContent() method on the TestResponse instance. This is an excellent helper method for getting the content as string from a StreamResponse instance—check out the Symfony HttpFoundation Component) for more information about the StreamResponse class.

Our test scaffolds five users and uses the first user in the collection to make an authenticated request to the export endpoint. Since we haven’t written any controller code, our test will fail to assert that we don’t have a StreamResponse instance:

1$ phpunit --filter=authenticated_users_can_export_all_users
2PHPUnit 7.5.9 by Sebastian Bergmann and contributors.
3
4F 1 / 1 (100%)
5
6Time: 183 ms, Memory: 20.00 MB
7
8There was 1 failure:
9
101) Tests\Feature\UsersExportTest::authenticated_users_can_export_all_users
11The response is not a streamed response.

Let’s get this test passing with as little code as needed. Change the invokable UsersExportController class to the following:

1/**
2 * Handle the incoming request.
3 *
4 * @param \Illuminate\Http\Request $request
5 * @return \Illuminate\Http\Response
6 */
7public function __invoke(Request $request)
8{
9 return response()->streamDownload(function () {
10 echo "hello world";
11 }, 'users.txt');
12}

If we rerun the PHPUnit test we should have a string representation of our StreamResponse instance:

1$ phpunit --filter=authenticated_users_can_export_all_users
2PHPUnit 7.5.9 by Sebastian Bergmann and contributors.
3
4"hello world"

Further, if you want to experiment with the content-disposition header and assert that the response is indeed forcing a download you can add the following lines:

1/** @test */
2public function authenticated_users_can_export_all_users()
3{
4 $users = factory('App\User', 5)->create();
5
6 $response = $this->actingAs($users->first())->get('/users/export');
7 $response->assertHeader('Content-Disposition', 'attachment; filename=users.txt');
8 $content = $response->streamedContent();
9 dd($content);
10}

Laravel has tests for the streamDownload feature, but it doesn’t hurt to check the header to make sure we are forcing a download for the export endpoint to ensure we are sending the appropriate response headers from our controller.

Testing a CSV Export

We have a basic test in place for testing the StreamedResponse instance our controller returns, and now it’s time to move on to generating and testing a CSV export.

We are going to rely on the PHP League CSV package to generate and test our endpoint:

1composer require league/csv

Next, let’s continue writing our test, first asserting that the CSV row count matches the count of users in the database:

1// Import the CSV Reader instance at the top...
2use League\Csv\Reader as CsvReader;
3
4// ...
5
6/** @test */
7public function authenticated_users_can_export_all_users()
8{
9 $users = factory('App\User', 5)->create();
10
11 $response = $this->actingAs($users->first())->get('/users/export');
12 $response->assertHeader('Content-Disposition', 'attachment; filename=users.txt');
13
14 $reader = CsvReader::createFromString($response->streamedContent());
15 $reader->setHeaderOffset(0);
16
17 $this->assertCount(User::count(), $reader);
18}

We first create a new CSV reader instance and set the header offset as the first row (we haven’t written the CSV file in the controller yet) which will match our CSV columns shortly. Setting the header offset means that the Countable CSV reader instance will ignore the header row as part of the row count.

To make this pass, we need to create a CSV writer instance and add our users:

1<?php
2
3namespace App\Http\Controllers;
4
5use App\User;
6use Illuminate\Http\Request;
7use League\Csv\Writer as CsvWriter;
8
9class UsersExportController extends Controller
10{
11 /**
12 * @var \League\Csv\Writer
13 */
14 private $writer;
15
16 /**
17 * Handle the incoming request.
18 *
19 * @param \Illuminate\Http\Request $request
20 * @return \Illuminate\Http\Response
21 */
22 public function __invoke(Request $request)
23 {
24 $this->writer = CsvWriter::createFromString('');
25 $this->writer->insertOne([
26 'Name', 'Email', 'Email Verified', 'Created', 'Updated'
27 ]);
28
29 User::all()->each(function ($user) {
30 $this->addUserToCsv($user);
31 });
32
33 return response()->streamDownload(function () {
34 echo $this->writer->getContent();
35 }, 'users.csv');
36 }
37
38 private function addUserToCsv(User $user)
39 {
40 $this->writer->insertOne([
41 $user->name,
42 $user->email,
43 $user->email_verified_at->format('Y-m-d'),
44 $user->created_at->format('Y-m-d'),
45 $user->updated_at->format('Y-m-d'),
46 ]);
47 }
48}

There’s a lot to unpack here, but most importantly we create a CSV writer instance and add the users from the database to the writer. Finally, we output the content of the CSV file in the streamDownload closure.

Also note that we changed the filename to users.csv and need to adjust our test to match. Let’s also check out the streamed content to see our raw CSV file in action:

1$response->assertHeader('Content-Disposition', 'attachment; filename=users.csv');
2dd($response->streamedContent());

You should see something like the following with the five factory users we added in the test:

Finally, if you remove the call to dd() the test should now pass:

1phpunit --filter=authenticated_users_can_export_all_users
2PHPUnit 7.5.9 by Sebastian Bergmann and contributors.
3
4. 1 / 1 (100%)
5
6Time: 180 ms, Memory: 22.00 MB
7
8OK (1 test, 3 assertions)

Here’s what our test case looks like right now:

1/** @test */
2public function authenticated_users_can_export_all_users()
3{
4 $this->withoutExceptionHandling();
5 $users = factory('App\User', 5)->create();
6
7 $response = $this->actingAs($users->first())->get('/users/export');
8 $response->assertHeader('Content-Disposition', 'attachment; filename=users.csv');
9
10 $reader = CsvReader::createFromString($response->streamedContent());
11 $reader->setHeaderOffset(0);
12
13 $this->assertCount(User::count(), $reader);
14}

Checking the Records

We can verify that the records contain the expected values from each of our users now by reverse engineering the CSV writer with our CSV reader in the test:

1/** @test */
2public function authenticated_users_can_export_all_users()
3{
4 $this->withoutExceptionHandling();
5 $users = factory('App\User', 5)->create();
6
7 $response = $this->actingAs($users->first())->get('/users/export');
8 $response->assertHeader('Content-Disposition', 'attachment; filename=users.csv');
9
10 $reader = CsvReader::createFromString($response->streamedContent());
11 $reader->setHeaderOffset(0);
12
13 $allUsers = User::all();
14 $this->assertCount($allUsers->count(), $reader);
15
16 foreach ($reader->getRecords() as $record) {
17 $index = $allUsers->search(function ($user) use ($record) {
18 return $user->email === $record['Email'];
19 });
20
21 $this->assertNotFalse($index);
22
23 $found = $allUsers->get($index);
24
25 $this->assertEquals($found->name, $record['Name']);
26 $this->assertEquals($found->email, $record['Email']);
27 $this->assertEquals($found->email_verified_at->format('Y-m-d'), $record['Email Verified']);
28 $this->assertEquals($found->created_at->format('Y-m-d'), $record['Created']);
29 $this->assertEquals($found->updated_at->format('Y-m-d'), $record['Updated']);
30
31 $allUsers->forget($index);
32 }
33
34 $this->assertCount(0, $allUsers, 'All users should be accounted for in the CSV file.');
35}

Our last test case has a lot of new lines, but they’re not super complicated. We are first querying the database for all records separately from our $users variable from the factory. We want to get a fresh collection of all users straight from the database.

Next, we verify that the number of rows in the CSV file matches the database collection. Using the CSV rows, we search for users in the $allUsers collection to make sure we account for each user. We assert the format of columns and finally remove the user from the $allUsers collection at the bottom of our loop.

The final assertion guarantees that all users are removed from the temporary collection by being represented as a row in the CSV file.

Conclusion

While we got into the details of writing and testing this feature, the big takeaway from this tutorial is the TestResponse::streamedContent() method to get the streamed file to verify the content. One of the neatest parts of this tutorial is the realization that we can generate plaintext files from a stream without saving a file to disk first! The possibility of representing model data as a streamed download without exporting a file is a fantastic feature in my opinion!

Filed in:

Paul Redmond

Full stack web developer. Author of Lumen Programming Guide and Docker for PHP Developers.