Testing Streamed Responses in Laravel

Published on by

Testing Streamed Responses in Laravel image

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 file
return 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-response
cd testing-stream-response
php 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_export
PHPUnit 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_export
Expected 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_export
PHPUnit 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_export
UnexpectedValueException: 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_export
PHPUnit 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_export
Expected 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_export
PHPUnit 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 trait
use 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_users
PHPUnit 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_users
The 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_users
PHPUnit 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_users
PHPUnit 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!

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate logo

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate

Build your SaaS application in hours. Out-of-the-box multi-tenancy and seamless Stripe integration. Supports subscriptions and one-time purchases, allowing you to focus on building and creating without repetitive setup tasks.

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Asymmetric Property Visibility in PHP 8.4 image

Asymmetric Property Visibility in PHP 8.4

Read article
Access Laravel Pulse Data as a JSON API image

Access Laravel Pulse Data as a JSON API

Read article
Laravel Forge adds Statamic Integration image

Laravel Forge adds Statamic Integration

Read article
Transform Data into Type-safe DTOs with this PHP Package image

Transform Data into Type-safe DTOs with this PHP Package

Read article
PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell image

PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell

Read article
Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3 image

Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3

Read article