Testing Streamed Responses in Laravel
Published on by Paul Redmond
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):
// Download response for a filesystem filereturn response()->download($pathToFile, $name, $headers);
If you are providing an export of customers, users, or another database record the response()->streamDownload()
is really awesome:
return response()->streamDownload(function () { echo GitHub::api('repo') ->contents() ->readme('laravel', 'laravel')['contents'];}, '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:
laravel new testing-stream-responsecd testing-stream-responsephp 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:
<php> <!-- ... --> <server name="DB_CONNECTION" value="sqlite"/> <server name="DB_DATABASE" value=":memory:"/></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:
php 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
.
<?php namespace Tests\Feature; use Tests\TestCase;use Illuminate\Foundation\Testing\WithFaker;use Illuminate\Foundation\Testing\RefreshDatabase; class UsersExportTest extends TestCase{ /** @test */ public function guests_cannot_download_users_export() { $this->get('/users/export') ->assertStatus(302) ->assertLocation('/login'); }}
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:
$ phpunit --filter=guests_cannot_download_users_exportPHPUnit 7.5.9 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 113 ms, Memory: 14.00 MB There was 1 failure: 1) Tests\Feature\UsersExportTest::guests_cannot_download_users_exportExpected status code 302 but received 404.Failed asserting that false is true.
We haven’t defined a route, so let’s do so now in the routes/web.php
file:
Route::get('/users/export', 'UsersExportController');
Rerunning the test brings us to the next failure that we need to fix:
phpunit --filter=guests_cannot_download_users_exportPHPUnit 7.5.9 by Sebastian Bergmann and contributors. E 1 / 1 (100%) Time: 96 ms, Memory: 12.00 MB There was 1 error: 1) Tests\Feature\UsersExportTest::guests_cannot_download_users_exportUnexpectedValueException: 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:
// Route::get('/users/export', 'UsersExportController');
Now you can create a new invokable controller with the artisan command:
php artisan make:controller -i UsersExportController
Uncomment the route we added and rerun the tests to get the next test failure:
phpunit --filter=guests_cannot_download_users_exportPHPUnit 7.5.9 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 122 ms, Memory: 14.00 MB There was 1 failure: 1) Tests\Feature\UsersExportTest::guests_cannot_download_users_exportExpected 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.
Route::get('/users/export', 'UsersExportController') ->middleware('auth');
Finally, our test should pass:
phpunit --filter=guests_cannot_download_users_exportPHPUnit 7.5.9 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 118 ms, Memory: 14.00 MB OK (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.
// Make sure to import the RefreshDatabase traituse RefreshDatabase; /** @test */public function authenticated_users_can_export_all_users(){ $users = factory('App\User', 5)->create(); $response = $this->actingAs($users->first())->get('/users/export'); $content = $response->streamedContent(); dd($content);}
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:
$ phpunit --filter=authenticated_users_can_export_all_usersPHPUnit 7.5.9 by Sebastian Bergmann and contributors. F 1 / 1 (100%) Time: 183 ms, Memory: 20.00 MB There was 1 failure: 1) Tests\Feature\UsersExportTest::authenticated_users_can_export_all_usersThe 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:
/** * Handle the incoming request. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\Response */public function __invoke(Request $request){ return response()->streamDownload(function () { echo "hello world"; }, 'users.txt');}
If we rerun the PHPUnit test we should have a string representation of our StreamResponse instance:
$ phpunit --filter=authenticated_users_can_export_all_usersPHPUnit 7.5.9 by Sebastian Bergmann and contributors. "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:
/** @test */public function authenticated_users_can_export_all_users(){ $users = factory('App\User', 5)->create(); $response = $this->actingAs($users->first())->get('/users/export'); $response->assertHeader('Content-Disposition', 'attachment; filename=users.txt'); $content = $response->streamedContent(); dd($content);}
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:
composer 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:
// Import the CSV Reader instance at the top...use League\Csv\Reader as CsvReader; // ... /** @test */public function authenticated_users_can_export_all_users(){ $users = factory('App\User', 5)->create(); $response = $this->actingAs($users->first())->get('/users/export'); $response->assertHeader('Content-Disposition', 'attachment; filename=users.txt'); $reader = CsvReader::createFromString($response->streamedContent()); $reader->setHeaderOffset(0); $this->assertCount(User::count(), $reader);}
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:
<?php namespace App\Http\Controllers; use App\User;use Illuminate\Http\Request;use League\Csv\Writer as CsvWriter; class UsersExportController extends Controller{ /** * @var \League\Csv\Writer */ private $writer; /** * Handle the incoming request. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\Response */ public function __invoke(Request $request) { $this->writer = CsvWriter::createFromString(''); $this->writer->insertOne([ 'Name', 'Email', 'Email Verified', 'Created', 'Updated' ]); User::all()->each(function ($user) { $this->addUserToCsv($user); }); return response()->streamDownload(function () { echo $this->writer->getContent(); }, 'users.csv'); } private function addUserToCsv(User $user) { $this->writer->insertOne([ $user->name, $user->email, $user->email_verified_at->format('Y-m-d'), $user->created_at->format('Y-m-d'), $user->updated_at->format('Y-m-d'), ]); }}
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:
$response->assertHeader('Content-Disposition', 'attachment; filename=users.csv');dd($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:
phpunit --filter=authenticated_users_can_export_all_usersPHPUnit 7.5.9 by Sebastian Bergmann and contributors. . 1 / 1 (100%) Time: 180 ms, Memory: 22.00 MB OK (1 test, 3 assertions)
Here’s what our test case looks like right now:
/** @test */public function authenticated_users_can_export_all_users(){ $this->withoutExceptionHandling(); $users = factory('App\User', 5)->create(); $response = $this->actingAs($users->first())->get('/users/export'); $response->assertHeader('Content-Disposition', 'attachment; filename=users.csv'); $reader = CsvReader::createFromString($response->streamedContent()); $reader->setHeaderOffset(0); $this->assertCount(User::count(), $reader);}
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:
/** @test */public function authenticated_users_can_export_all_users(){ $this->withoutExceptionHandling(); $users = factory('App\User', 5)->create(); $response = $this->actingAs($users->first())->get('/users/export'); $response->assertHeader('Content-Disposition', 'attachment; filename=users.csv'); $reader = CsvReader::createFromString($response->streamedContent()); $reader->setHeaderOffset(0); $allUsers = User::all(); $this->assertCount($allUsers->count(), $reader); foreach ($reader->getRecords() as $record) { $index = $allUsers->search(function ($user) use ($record) { return $user->email === $record['Email']; }); $this->assertNotFalse($index); $found = $allUsers->get($index); $this->assertEquals($found->name, $record['Name']); $this->assertEquals($found->email, $record['Email']); $this->assertEquals($found->email_verified_at->format('Y-m-d'), $record['Email Verified']); $this->assertEquals($found->created_at->format('Y-m-d'), $record['Created']); $this->assertEquals($found->updated_at->format('Y-m-d'), $record['Updated']); $allUsers->forget($index); } $this->assertCount(0, $allUsers, 'All users should be accounted for in the CSV file.');}
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!