在 Laravel 中测试流式响应
发布日期 作者: Paul Redmond
Laravel 提供了一个方便的 API 来生成 HTTP 响应,该响应会强制用户的浏览器下载给定 URL 的文件。在编写包含文件下载功能的应用程序时,Laravel 提供了愉快的测试体验,使编写和测试可下载文件变得轻而易举。
让我们通过一个在 Laravel 应用程序中创建和测试可下载 URL 的实际示例。
简介
在本教程中,我们将构建一个快速的用户导出到 CSV 功能,该功能允许用户下载数据库中所有用户的 CSV 导出文件。为了强制用户下载,Laravel 有一个 download
方法,该方法接受文件的路径(如 响应文档 中所述)。
// Download response for a filesystem filereturn response()->download($pathToFile, $name, $headers);
如果您正在提供客户、用户或其他数据库记录的导出,response()->streamDownload()
非常棒
return response()->streamDownload(function () { echo GitHub::api('repo') ->contents() ->readme('laravel', 'laravel')['contents'];}, 'laravel-readme.md');
本教程快速演示了如何编写和测试使用流式文件响应的控制器。我想指出,在实际应用中,您应该根据应用程序的业务规则提供一些围绕用户导出安全机制。
设置应用程序
首先,我们需要创建一个新的 Laravel 应用程序并搭建身份验证。
laravel new testing-stream-responsecd testing-stream-responsephp artisan make:auth
接下来,让我们为我们的测试环境配置一个 SQLite 数据库。本教程将使用测试来驱动功能,您可以自由配置任何类型的数据库来在浏览器中试用该应用程序。
打开项目根目录中的 phpunit.xml
文件,并添加以下环境变量
<php> <!-- ... --> <server name="DB_CONNECTION" value="sqlite"/> <server name="DB_DATABASE" value=":memory:"/></php>
我们搭建了一个快速的 Laravel 应用程序,并生成了保护用户导出路由所需的身份验证文件。我们准备开始测试导出并编写控制器。
编写测试
我们将使用 PHPUnit 功能测试来驱动此功能,并在过程中构建控制器逻辑。
首先,我们将创建一个新的功能测试
php artisan make:test UsersExportTest
在这个文件中,我们将搭建第一个测试,该测试的期望是,访客在尝试访问 /users/export
时被重定向到登录 URL。
<?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'); }}
我们正在请求导出端点,并期望重定向响应到登录页面。
我们甚至没有定义路由,因此当我们运行测试时,此测试将失败
$ 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.
我们还没有定义路由,所以现在让我们在 routes/web.php
文件中定义路由
Route::get('/users/export', 'UsersExportController');
重新运行测试会让我们遇到需要修复的下一个错误
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].
接下来,生成控制器作为可调用操作。我发现对于文件导出,我倾向于使用可调用控制器,因为我希望在典型 RESTful 路由之外明确表示该控制器的意图。
在运行此命令之前,您需要注释掉我们添加到 routes/web.php
文件中的路由
// Route::get('/users/export', 'UsersExportController');
现在,您可以使用 artisan 命令创建一个新的可调用控制器
php artisan make:controller -i UsersExportController
取消注释我们添加的路由,并重新运行测试以获得下一个测试错误
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.
我们需要添加的最后一个代码更改是 auth
中间件,以保护路由免受访客访问。在本教程中,我们不假设任何权限——任何用户都可以下载用户的导出文件。
Route::get('/users/export', 'UsersExportController') ->middleware('auth');
最后,我们的测试应该通过
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)
开始导出功能
我们的用户导出测试和控制器已经到位,我们准备开始测试实际的流式下载功能。我们的第一步是在 UsersExportTest
测试类中创建下一个测试用例,该用例以经过身份验证的用户身份请求导出端点。
// 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);}
请注意 TestResponse
实例上的 streamedContent()
方法。这是一个极好的辅助方法,用于从 StreamResponse
实例中获取内容作为字符串——查看 Symfony HttpFoundation 组件 以了解有关 StreamResponse
类的更多信息。
我们的测试搭建了五个用户,并使用集合中的第一个用户向导出端点发出经过身份验证的请求。由于我们还没有编写任何控制器代码,因此我们的测试将无法断言我们没有 StreamResponse
实例
$ 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.
让我们尽可能少地使用代码来通过此测试。将可调用 UsersExportController
类更改为以下内容
/** * 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');}
如果我们重新运行 PHPUnit 测试,我们应该有一个 StreamResponse
实例的字符串表示
$ phpunit --filter=authenticated_users_can_export_all_usersPHPUnit 7.5.9 by Sebastian Bergmann and contributors. "hello world"
此外,如果您想尝试使用内容处置标头并断言响应确实强制下载,您可以在其中添加以下几行代码
/** @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 对 streamDownload
功能进行了测试,但检查标头以确保我们正在强制下载导出端点以确保我们从控制器发送适当的响应标头并没有什么坏处。
测试 CSV 导出
我们已经为测试控制器返回的 StreamedResponse
实例创建了一个基本测试,现在是时候继续生成和测试 CSV 导出文件了。
我们将依靠 PHP League CSV 包来生成和测试我们的端点
composer require league/csv
接下来,让我们继续编写测试,首先断言 CSV 行数与数据库中的用户数量匹配
// 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);}
我们首先创建一个新的 CSV 阅读器实例,并将标头偏移量设置为第一行(我们还没有在控制器中写入 CSV 文件),这将很快匹配我们的 CSV 列。设置标头偏移量意味着 Countable
CSV 阅读器实例将忽略标头行作为行数的一部分。
为了让它通过,我们需要创建一个 CSV 写入器实例,并将我们的用户添加到写入器中
<?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'), ]); }}
这里有很多内容需要解释,但最重要的是,我们创建了一个 CSV 写入器实例,并将数据库中的用户添加到写入器中。最后,我们在 streamDownload
闭包中输出 CSV 文件的内容。
还要注意,我们将文件名更改为 users.csv
,并且需要调整我们的测试以匹配。让我们也看看流式内容,看看我们的原始 CSV 文件的实际效果
$response->assertHeader('Content-Disposition', 'attachment; filename=users.csv');dd($response->streamedContent());
您应该看到类似以下内容,其中包含我们在测试中添加的五个工厂用户
最后,如果您删除对 dd()
的调用,测试现在应该通过
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)
以下是我们现在的测试用例的样子
/** @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);}
检查记录
现在,我们可以通过使用 CSV 阅读器在测试中对 CSV 写入器进行逆向工程来验证记录是否包含我们每个用户的预期值
/** @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.');}
我们的最后一个测试用例有很多新行,但它们并不复杂。我们首先查询数据库以获取所有记录,这些记录与我们来自工厂的 $users
变量分开。我们希望从数据库中直接获取所有用户的最新集合。
接下来,我们验证 CSV 文件中的行数是否与数据库集合匹配。使用 CSV 行,我们在$allUsers
集合中搜索用户,以确保我们对每个用户都进行了统计。我们断言列的格式,最后在循环底部从$allUsers
集合中删除用户。
最终的断言保证所有用户都从临时集合中删除,因为它们在 CSV 文件中以行形式表示。
结论
虽然我们深入探讨了编写和测试此功能的细节,但本教程中最大的收获是TestResponse::streamedContent()
方法,用于获取流式文件以验证其内容。本教程中最棒的部分之一是,我们可以从流中生成纯文本文件,而无需先将文件保存到磁盘!在我看来,能够以流式下载的形式表示模型数据,而无需导出文件,这是一个非常棒的功能!