Testing Streamed Responses in Laravel

Testing Streamed Responses in Laravel

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!


Filed in: Laravel Tutorials


Newsletter

Join the weekly newsletter and never miss out on new tips, tutorials, and more.

Laravel News Partners

Laravel Jobs

Senior Laravel Developer
Remote
Sonar
Laravel / Vue.JS Developer - Join an amazing team!
Remote
JTR Solutions
Mid / Sen. Software Engineer
Clearwater, FL
ShineOn
Remote PHP / Laravel Developer
Remote
SpringboardVR
Senior PHP/Laravel Developer: Your Dream Work Environment
Remote
iPhone Photography School
Senior Laravel Developer
Leidseplein, Amsterdam
Orderchamp.com
PHP Developer
Remote
X-Team