核心要点
- 在应用开发中,测试是很重要的,在诸多的开发工具中,测试驱动开发是很伟大的一项;
- 测试文件上传并不像人们想象的那么简单;
- 目前,有很多很棒,但不为大家所熟知的测试工具;
- Larval 能够让请求的校验更容易;
- 测试并不需要实际的文件系统,因为如果这样做的话,会在项目中引入添加 / 移除文件的代码,从而导致噪音的出现。
文件上传的测试可能会非常棘手,但是通过使用恰当的工具,再掌握一些技巧,这个过程可以会更加高效,也能更容易。
如果你之前没有了解过 vfsStream 的话,那么简单来讲,它允许与保存在内存中的文件进行交互,而不是与机器中实际存在文件进行交互。这样做的好处在于我们并不需要删除用于测试的文件,如果测试失败,teardown 或者其他用于移除测试文件的代码没有执行,那么这就更会成为一个问题了。除此之外,因为它处理的是内存而不是物理的硬件驱动器,所以它会更快。简而言之,它能够更加整洁,速度也会更快。
本文将会创建一个端点(路由)来上传包含用户信息的 CSV 文件,并测试 CSV 文件中的用户能够展现到一个 JSON 格式的响应中,同时还会添加校验功能,确保所处理的文件是 CSV 类型。这是一个超出现实的样例,不过它可以作为一个很好的起点,帮助你在项目中实现类似的功能。
在本文中,我们假设你已经安装好了 Laravel(我们所使用的是 5.3 版本)。你不用实际去点击这个路由,但是我们建议你使用 Laravel Valet 或 Homestead 。在这个指南中,不会包含如何让它们就绪并运行起来的内容,通过这个仓库的链接中,你可以获取本文所对应的代码。
起步
打开控制台并导航至项目的根文件夹(在这里你可以看到composer.json 文件和.env 文件等内容),如果你运行:
composer require mikey179/vfsStream --dev
那么就会将 vfsStream 拉取到 composer file 文件的 development 部分中,同时还会拉取所需的文件,从而能够在测试中使用这个包。完成这些之后,我们就为测试做好了准备。
采用的 TDD 的方式编写代码
为了让这个过程尽可能简单,只需在 tests 文件夹(同样在项目的根目录下)中创建一个名为 UploadCsvTest.php 的文件。现在,我们需要打开这个文件并将如下的内容添加进去。
<?php class UploadCsvTest extends TestCase { public function testSuccessfulUploadingCsv() { } }
首先,我们让文件上传运行起来,然后可以再回过头来再添加校验功能。采取这种方式的原因在于,我们能够为可运行的代码迭代式地添加功能。例如,如果你能上传文件的话,那么它就是可运行的代码,为其添加校验是对功能的增强,可以稍后再进行。
现在,添加如下的代码到“testSuccessfullUploadingCsv”函数中:
$this->json('POST', 'upload-new-users-csv', ['usersCsvFile' => 'someFile']); $this->assertResponseOk();
如果通过控制台运行(稍后,我们将会通过 PHPUnit 来引用这条命令)下面的命令的话:
phpunit --filter=UploadCsvTest
不出意料,这里会发生失败,看起来会像如下所示:
Expected status code 200, got 404. Failed asserting that false is true.
404 响应意味着无法找到路由,现在我们来定义它。导航至 routes/web.php 文件(从上一个 Laravel 版本开始,路由文件已经从 Http 目录转移到了这里),并且在文件的底部添加下面的代码:
Route::post('/upload-new-users-csv', function (\Illuminate\Http\Request $request) { });
现在,如果我们运行 PHPUnit 的话,测试就能通过了。所以,接下来我们就应该在路由文件的闭包中访问预期的文件了。因此,回到这个文件中,并在闭包中添加如下这行代码:
$request->input('usersCsvFile')->openFile();
此时,路由看起来如下所示:
Route::post('/upload-new-users-csv', function (\Illuminate\Http\Request $request) { $request->input('usersCsvFile')->openFile(); });
现在,如果你运行 PHPUnit 的话,会得到另外的错误,这是一个 500 错误。在这里,要查清发生了什么需要一点技巧。我们可以在测试中通过如下的方式来查看响应的内容:
dd($this->response->getContent());
但是很重要的一点在于,它要在断言响应 OK 之前执行,否则的话,在我们得到 dd 输出之前,测试就已经失败了。另外一种方式就是查看位于“storage/logs/laravel.log”中的日志。不管采用哪种检查方式,我们都会看到类似于这样的信息:“使用字符串的形式调用了成员函数 openFile()”,这其实就是我们要达到的效果,因为在测试中,当我们发起 post 请求时,发送的是一个字符串。接下来,该看一下 vfs 了!
熟悉 vfsStream
它并不全面,但这是一个好的开端,这是一个非常深入和强大的包。为了让测试看起来更棒一些,我们先将创建文件的内容放到一个函数中。所以,回到测试文件(UploadCsvTest.php)中,将其修改成如下所示:
<?php use Illuminate\Http\UploadedFile; use org\bovigo\vfs\vfsStream; class UploadCsvTest extends TestCase { public function testSuccessfulUploadingCsv() { $this->json('POST', 'upload-new-users-csv', ['usersCsvFile' => $this->createUploadFile()]); $this->assertResponseOk(); } protected function createUploadFile() { $vfs = vfsStream::setup(sys_get_temp_dir(), null, ['testFile' => 'someString']); return new UploadedFile( $vfs->url().'/testFile', null, 'text/csv', null, null, true ); } }
这里引入了很多内容,首先是 vfsStream::setup,它会在内存中创建一个文件。样例中使用了“sys_get_temp_dir()”,这只是让它看起来“更真实一些”,这里可以使用任意的值,比如可以将其修改为字符串“root”,运行效果是完全一样的。这背后的理念是允许你创建任意的目录结构以适应实际的需求。
这其实并不太重要,因为 PHP 会自动将上传的文件放到系统的临时目录中,所以只需使用“sys_get_temp_dir()”就可以,在这个场景中,它其实并没有什么关系。下一个参数用来设置文件权限,在本例中,我们没有特殊的要求,所以只需将其设置为 null,这样允许你对文件进行任意操作。最后,是目录(本例中也就是“/tmp”)中的内容,它只是一个数组,由文件名(key)和值(现在是普通的字符串,不过后续需要改为 CSV 内容)所组成。
最后,我们可以看到的是 UploadedFile,它是对 Symfony 的 UploadedFile 的扩展,我们按顺序快速看一下它的参数(来源于 Symfony 的注释内容):
第一个参数:文件的完整临时目录;
第二个参数:原始的文件名;
第三个参数:PHP 所提供的文件类型,如果是 null 的话,默认为 application/octet-stream;
第四个参数:文件大小;
第五个参数:上传出现错误的常量(PHP 的 UPLOAD_ERR_XXX 常量之一),如果是 null 的话,默认为 UPLOAD_ERR_OK;
第六个参数:是否启用测试模式。
如果你运行 PHPUnit 的话,那么测试就能够通过了!现在,我们让文件看起来更真实一些,在 createUploadFile 函数中,将它的内容改为如下所示:
protected function createUploadFile() { $vfs = vfsStream::setup(sys_get_temp_dir(), null, ['testFile.csv' => '']); $uploadFile = new UploadedFile( $vfs->url().'/testFile.csv', null, 'text/csv', null, null, true ); collect([ ['username', 'first name', 'last name'], ['jondoe', 'Jon', 'Doe'], ['janedoe', 'Jane', 'Doe'] ])->each(function ($fields) use ($uploadFile) { $uploadFile->openFile('a+')->fputcsv($fields); }); return $uploadFile; }
这里利用了 Laravel 的集合辅助工具,借助一些 PHP 的功能来填充文件。openFile() 会得到底层的 SPLFileObject ,然后直接使用它,将数组中的元素添加到文件中,就像 CSV 文件中的某行数据一样。运行 PHPUnit,我们依然会得到绿色的测试通过提示。现在,我们要确保能够得到预期的结果,响应中的 JSON 要列出文件中的内容。要实现这项功能,将 testSuccessfulUpload 函数改成如下所示:
public function testSuccessfulUploadingCsv() { $this->json('POST', 'upload-new-users-csv', ['usersCsvFile' => $this->createUploadFile()]) ->seeJson([ "username,\"first name\",\"last name\"\n","jondoe,Jon,Doe\n","janedoe,Jane,Doe\n" ]); $this->assertResponseOk(); }
在运行 PHPunit 之后,我们会得到红色的失败提示,所以,接下来我们要让测试通过。打开 web.php 文件,将路由闭包修改为如下所示:
Route::post('/upload-new-users-csv', function (\Illuminate\Http\Request $request) { $uploadedFile = $request->input('usersCsvFile')->openFile(); $returnArray = collect(); while (!$uploadedFile->eof()) { $returnArray->push($uploadedFile->fgets()); } return $returnArray; });
这里所做的就是从输入(post 请求)中读取 CSV 内容,并将其放到一个集合中,这样就能将其拆分出来。这不是一个很实际的好例子,但足以让测试能够通过。在运行 PHPUnit 之后,我们的测试应该依然是绿色通过状态。
现在,我们要创建具有破坏性的测试了,在此之前,要有不少的准备工作,这可能会超出不少人的预期。
首先,我们将“createUploadFile”函数做得更加具有可重用性,它依然能够让测试通过。现在,这个函数如下所示:
protected function createUploadFile(vfsStreamFile $file) { return new UploadedFile( $file->url(), null, mime_content_type($file->url()), null, null, true ); }
确保导入(使用)了“org\bovigo\vfs\vfsStreamFile”,否则,即便在我们预期测试能够通过的地方,也会出现错误。这样,我们就能够使用 vfsSream 来创建文件了,它能够知道文件位于何处(内存中),并且能够确定所创建文件的 MIME 类型。
现在,我们就能设法创建虚拟文件了:
protected function createVirtualFile($filename, $extension) { return vfsStream::setup(sys_get_temp_dir(), null, [$filename.'.'.$extension => '']); }
这是一个很简单的函数,并没有太多的内容。它只是根据给定的文件名和扩展名创建了一个虚拟文件。如果在后续的测试中,我们想要创建 CSV 以及其他类型的文件,这能够减少代码的重复。
现在,我们创建一个函数,它会使用上面提到的这些函数来创建一个 CSV 文件,这也是我们现在的测试所需要的:
protected function createCsvUploadFile($fileName = 'testFile') { $virtualFile = $this->createVirtualFile($fileName, 'csv')->getChild($fileName.'.csv'); $fileResource = fopen($virtualFile->url(), 'a+'); collect([ ['username', 'first name', 'last name'], ['jondoe', 'Jon', 'Doe'], ['janedoe', 'Jane', 'Doe'] ])->each(function ($fields) use ($fileResource) { fputcsv($fileResource, $fields); }); fclose($fileResource); return $this->createUploadFile($virtualFile); }
这个函数完成的工作要比前面的两个函数更多。首先,我们根据给定的名字创建了一个 CSV 文件,然后调用了“getChild”函数,这是一个新函数。它的作用就是从虚拟系统中得到虚拟文件,这样,我们就能以更加直接的方式来使用它了。在前面的代码中,不需要这样做,这是因为我们直接将文件路径提供给了 UploadFile,如下所示:
new UploadedFile( $vfs->url().'/testFile.'.$extension, // 在之前的代码中,使用了获取子文件的“手动”方式。 null, text/csv', // 在旧代码中,这里是硬编码的,我们无法测试其他的 MIME 类型。 null, null, true );
现在,回到“createCsvUploadFile”函数。在创建文件系统并从中得到 CSV 文件之后,接下来,我们就可以使用简单原始的 PHP 和 Laravel 来加载文件,并使用已有的旧数据生成 CSV 文件的内容。然后,将其传递给“createUploadFile”函数,这样的话,在测试函数中(“testSuccessfulUploadingCsv”)就得到了一个可用的上传文件。
这里需要修改的就是使用新的“createCsvUploadFile”函数,这个修改非常简单:
// 这一行 $this->createUploadFile('.csv') // 需要变更为: $this->createCsvUploadFile() // 如果你喜欢的话,可以填充文件名称。我添加了这个参数以备将来需要的时候使用。
到此完工!
此时,我们的测试文件如下所示:
<?php use Illuminate\Http\UploadedFile; use org\bovigo\vfs\vfsStream; use org\bovigo\vfs\vfsStreamFile; class UploadCsvTest extends TestCase { public function testSuccessfulUploadingCsv() { $this->json('POST', 'upload-new-users-csv', [ 'usersCsvFile' => $this->createCsvUploadFile() ]); $this->assertResponseOk(); $this->seeJson(["username,\"first name\",\"last name\"\n","jondoe,Jon,Doe\n","janedoe,Jane,Doe\n"]); } protected function createCsvUploadFile($fileName = 'testFile') { $virtualFile = $this->createVirtualFile($fileName, 'csv')->getChild($fileName.'.csv'); $fileResource = fopen($virtualFile->url(), 'a+'); collect([ ['username', 'first name', 'last name'], ['jondoe', 'Jon', 'Doe'], ['janedoe', 'Jane', 'Doe'] ])->each(function ($fields) use ($fileResource) { fputcsv($fileResource, $fields); }); fclose($fileResource); return $this->createUploadFile($virtualFile); } protected function createUploadFile(vfsStreamFile $file) { return new UploadedFile( $file->url(), null, mime_content_type($file->url()), null, null, true ); } protected function createVirtualFile($filename, $extension) { return vfsStream::setup(sys_get_temp_dir(), null, [$filename.'.'.$extension => '']); } }
如果你运行测试的话,会得到测试通过的绿色提示(符合预期)。
回过头来再执行一些 TDD 的流程
现在,我们可以使用一个非 CSV 的文件来破坏这个上传功能。因为 PHP 可以相对比较容易地创建图片文件,所以这是一个不错的选择。编写一个生成这种文件的函数,并将其填充到上传文件中:
protected function createImageUploadFile($fileName = 'testFile', $extension = 'jpeg') { $virtualFile = $this->createVirtualFile($fileName, $extension)->getChild($fileName.'.'.$extension); imagejpeg(imagecreate(500, 90), $virtualFile->url()); return $this->createUploadFile($virtualFile); }
这个函数非常简单直接。通常情况下,你可能在创建文件和 getChild 函数中,对文件类型进行硬编码,不过在这里我们试图骗过上传功能。这里需要确保一个带有 csv 扩展名的图片无法欺骗我们的上传功能。因此,在创建完空文件之后,你会发现在文件中创建了一个 jpeg(通过 imagejpeg 和 imagecreate 函数),并将其传递到“createUploadFile”函数的创建过程中,然后返回给调用者。接下来,要让它出现错误,我们只需创建新的测试函数:
public function testOnlyCsvsCanBeUploadedRegardlessOfExtension() { $this->json('POST', 'upload-new-users-csv', [ 'usersCsvFile' => $this->createImageUploadFile('testFile', 'csv') ]); $this->assertResponseStatus(422); }
这个样例会试图上传欺骗文件,也就是带有 csv 后缀的图片文件,我们预期得到 422 错误(Unprocessable Entity)。如果运行 phpunit 的话,将会得到一个错误,提示信息类似于“预期得到 422 状态码,实际得到的是 500”。这个失败完全符合我们的预期,接下来对其进行修正。
现在,我们要使用 Laravel 的 Form Request,可以通过 Artisan 很容易地实现该功能。回到控制台,如果你运行如下命令的话::
php artisan make:request UploadCsvUsers
Laravel 将会在 app/Http/Requests 目录下创建一个名为 UploadCsvUsers.php 的文件,打来这个文件,我们将会看到:
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class UploadCsvUsers extends FormRequest { /** * Determine if the user is authorized to make this request. * * @return bool */ public function authorize() { return false; } /** * Get the validation rules that apply to the request. * * @return array */ public function rules() { return [ // ]; } }
如果你感兴趣的话,可以在 这里阅读关于它的更多资料。修改“authorize”函数,将它的返回值默认设置为true(如果它返回false 的话,将会停止请求并且不再运行规则)。现在,将规则置空,然后跳转到routes/web.php 文件,在路由中添加表单请求,看一下会发生什么。你的web.php 文件看起来将会类似这样:
use App\Http\Requests\UploadCsvUsers; Route::post('/upload-new-users-csv', function (UploadCsvUsers $request) { $uploadedFile = $request->usersCsvFile->openFile(); $returnArray = collect(); while (!$uploadedFile->eof()) { $returnArray->push($uploadedFile->fgets()); } return $returnArray; });
在 phpunit 中运行这些代码,我们依然将会失败。为了避免出现问题,你可以在这里查阅Laravel 校验的所有可用选项。不过我们现在关注“根据文件扩展名确定MIME 类型(MIME Type By File Extension)”这一规则,所以,编辑我们的Request 并将其改为如下所示:
public function rules() { return [ 'usersCsvFile' => 'required|mimes:csv,txt' ]; }
因为我们知道想要使用的文件名,所以使用它作为 key,然后在这里添加几条规则。首先,它声明对于上传功能来说,这个文件是必需的,这个功能在这里并没有进行测试,你可以自行编写测试。只需编写一个上传参数中不包含“usersCsvFile”的测试,就可以看到这个请求如何运行了。这里的 mines 规则会接受 csv 或文本文件,因为 csv 文件本身就是简单的文本。
现在,如果你运行 PHPUnit 的话,所有的测试都会绿色通过状态,你也就正式完工了。
这是一个很棒的测试,它不仅能够像我们希望的那样读取 CSV 文件,还会拒绝伪装成 CSV 的文件。
结论
当第一次测试文件上传和 MIME 类型校验时,我们很容易感到手足无措。如果我们使用验收(浏览器)测试来做这件事的话,通常更是如此,但是测试的执行时间以及构建这种测试所需付出的精力都会代价高昂,但实际上这些测试跟真正的用户界面并没有太大的(甚至完全没有)关系。
这是一种更快更轻量级的测试方式。这不是一个非常完备的样例,却是一个很好的起点。另外,如果你想要进行图片上传的话,稍微做点工作,对一些内容稍作调整,就能完全按照相同的方式来运行。
关于作者
Terry Rowland是 Enola Labs 的高级 Web 应用开发人员,这是一家位于德克萨斯州 Austin 的 Web 和移动应用开发公司。
评论