BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

组合使用Laravel和vfsStream测试文件上传

| 作者 Terry Rowland 关注 0 他的粉丝 ,译者 张卫滨 关注 13 他的粉丝 发布于 2017年1月24日. 估计阅读时间: 36 分钟 | QCon上海2018 关注大数据平台技术选型、搭建、系统迁移和优化的经验。

核心要点

  • 在应用开发中,测试是很重要的,在诸多的开发工具中,测试驱动开发是很伟大的一项;
  • 测试文件上传并不像人们想象的那么简单;
  • 目前,有很多很棒,但不为大家所熟知的测试工具;
  • Larval能够让请求的校验更容易;
  • 测试并不需要实际的文件系统,因为如果这样做的话,会在项目中引入添加/移除文件的代码,从而导致噪音的出现。

文件上传的测试可能会非常棘手,但是通过使用恰当的工具,再掌握一些技巧,这个过程可以会更加高效,也能更容易。

如果你之前没有了解过vfsStream的话,那么简单来讲,它允许与保存在内存中的文件进行交互,而不是与机器中实际存在文件进行交互。这样做的好处在于我们并不需要删除用于测试的文件,如果测试失败,teardown或者其他用于移除测试文件的代码没有执行,那么这就更会成为一个问题了。除此之外,因为它处理的是内存而不是物理的硬件驱动器,所以它会更快。简而言之,它能够更加整洁,速度也会更快。

本文将会创建一个端点(路由)来上传包含用户信息的CSV文件,并测试CSV文件中的用户能够展现到一个JSON格式的响应中,同时还会添加校验功能,确保所处理的文件是CSV类型。这是一个超出现实的样例,不过它可以作为一个很好的起点,帮助你在项目中实现类似的功能。

在本文中,我们假设你已经安装好了Laravel(我们所使用的是5.3版本)。你不用实际去点击这个路由,但是我们建议你使用Laravel ValetHomestead。在这个指南中,不会包含如何让它们就绪并运行起来的内容,通过这个仓库的链接中,你可以获取本文所对应的代码。

起步

打开控制台并导航至项目的根文件夹(在这里你可以看到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 RowlandEnola Labs的高级Web应用开发人员,这是一家位于德克萨斯州Austin的Web和移动应用开发公司。

查看英文原文:Using vfsStream to Test File Uploads with Laravel

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT