在 Laravel 中测试流式响应

发布日期 作者:

Testing Streamed Responses in Laravel image

Laravel 提供了一个方便的 API 来生成 HTTP 响应,该响应会强制用户的浏览器下载给定 URL 的文件。在编写包含文件下载功能的应用程序时,Laravel 提供了愉快的测试体验,使编写和测试可下载文件变得轻而易举。

让我们通过一个在 Laravel 应用程序中创建和测试可下载 URL 的实际示例。

简介

在本教程中,我们将构建一个快速的用户导出到 CSV 功能,该功能允许用户下载数据库中所有用户的 CSV 导出文件。为了强制用户下载,Laravel 有一个 download 方法,该方法接受文件的路径(如 响应文档 中所述)。

// Download response for a filesystem file
return 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-response
cd testing-stream-response
php 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_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.

我们还没有定义路由,所以现在让我们在 routes/web.php 文件中定义路由

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

重新运行测试会让我们遇到需要修复的下一个错误

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].

接下来,生成控制器作为可调用操作。我发现对于文件导出,我倾向于使用可调用控制器,因为我希望在典型 RESTful 路由之外明确表示该控制器的意图。

在运行此命令之前,您需要注释掉我们添加到 routes/web.php 文件中的路由

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

现在,您可以使用 artisan 命令创建一个新的可调用控制器

php artisan make:controller -i UsersExportController

取消注释我们添加的路由,并重新运行测试以获得下一个测试错误

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.

我们需要添加的最后一个代码更改是 auth 中间件,以保护路由免受访客访问。在本教程中,我们不假设任何权限——任何用户都可以下载用户的导出文件。

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

最后,我们的测试应该通过

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)

开始导出功能

我们的用户导出测试和控制器已经到位,我们准备开始测试实际的流式下载功能。我们的第一步是在 UsersExportTest 测试类中创建下一个测试用例,该用例以经过身份验证的用户身份请求导出端点。

// 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);
}

请注意 TestResponse 实例上的 streamedContent() 方法。这是一个极好的辅助方法,用于从 StreamResponse 实例中获取内容作为字符串——查看 Symfony HttpFoundation 组件 以了解有关 StreamResponse 类的更多信息。

我们的测试搭建了五个用户,并使用集合中的第一个用户向导出端点发出经过身份验证的请求。由于我们还没有编写任何控制器代码,因此我们的测试将无法断言我们没有 StreamResponse 实例

$ 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.

让我们尽可能少地使用代码来通过此测试。将可调用 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_users
PHPUnit 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_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)

以下是我们现在的测试用例的样子

/** @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()方法,用于获取流式文件以验证其内容。本教程中最棒的部分之一是,我们可以从流中生成纯文本文件,而无需先将文件保存到磁盘!在我看来,能够以流式下载的形式表示模型数据,而无需导出文件,这是一个非常棒的功能!

Paul Redmond photo

Laravel News 的特约撰稿人。全栈 Web 开发人员和作家。

归档于
Cube

Laravel 新闻通讯

加入 40,000 多位其他开发者,不要错过任何新的技巧、教程等等。

Laravel Forge logo

Laravel Forge

轻松创建和管理您的服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动补全和对本地和生产环境的即时反馈进行调试。

Tinkerwell
No Compromises logo

绝不妥协

Joel 和 Aaron 是来自“绝不妥协”播客的两名经验丰富的开发者,现在可以为您的 Laravel 项目提供服务。⬧ 固定费用为每月 7500 美元。⬧ 没有冗长的销售流程。⬧ 没有合同。⬧ 100% 满意退款保证。

绝不妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,确保您的 Web 应用程序成功。

Kirschbaum
Shift logo

Shift

运行旧版本的 Laravel?即时、自动化的 Laravel 升级和代码现代化,让您的应用程序保持新鲜。

Shift
Bacancy logo

Bacancy

仅需每月 2500 美元,即可让您的项目拥有 4-6 年经验的经验丰富的 Laravel 开发人员。获得 160 小时的专业服务和 15 天的无风险试用。立即安排通话!

Bacancy
Lucky Media logo

Lucky Media

立即获得幸运 - 这是 Laravel 开发的理想选择,拥有十多年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel 电子商务

Laravel 的电子商务。一个开源软件包,将现代无头电子商务功能的强大功能带入 Laravel。

Lunar: Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 职位平台

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS 启动套件

SaaSykit 是一个 Laravel SaaS 启动套件,它包含运行现代 SaaS 所需的所有功能。付款、漂亮的结账、管理面板、用户仪表板、身份验证、现成组件、统计数据、博客、文档等等。

SaaSykit: Laravel SaaS 启动套件
Rector logo

Rector

您无缝升级 Laravel、降低成本和加速创新的合作伙伴,助力企业取得成功

Rector
MongoDB logo

MongoDB

通过强大的 MongoDB 和 Laravel 集成增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解 MongoDB 的灵活、现代数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的无依赖项输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

将 Swagger UI 添加到您的 Laravel 应用程序

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel 提示构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的站点搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 软件包为您的 Laravel 应用程序添加评论

阅读文章