重新认识 PHPUnit —— 从这里开始学习 PHP 下的 TDD(测试驱动开发)开发方法
75

file

在此之前,我将假设你熟悉 PHP面向对象风格 ,并且使用PHP7以上的版本进行开发。


为了能拥有一个正常运行的 PHP7 环境,并且可以跟随文中的步骤操作 且不出现奇怪的问题,我们推荐你使用 Homestead 作为开发环境。

本文中给出的结果实例将会是运行结束的样子,请不要担心,在操作中会有提示对你的操作进行引导。

这是一个比你想象中更加强大的工具

如果你仍在疑惑为什么我们推荐每个人使用 Vagrant Box,以下两篇文章将可以做出解释

测试驱动开发究竟是什么?

测试驱动开发是这样一种想法,就是说在真正开始开发之前,先编写一段测试代码,用来确保我们的想法能够如愿实现。

检查在 TDD-land 中 asserting(断言,如果不理解此概念,请参考 PHP 之 assert()函数) 是否确实是我们所期望的。记住这个术语。

举个例子,一个断言 2+2=4 是正确的。但是,如果我们觉得 2+3=4,那么测试框架(如 PHPUnit)会将此断言标记为 false,我们将这个称之为“失败的测试”。我们测试 2+3=4 失败了。很显然,在你的应用中你不会去测试常量之和,相反的,你会用变量替代,这时你便会得到断言的结果。

什么是 PHPUnit?

PHPUnit 是一个程序(PHP 类和可执行文件)的集合,它不仅使得编写测试变得简单(编写测试通常需要比编写实际应用的代码更多 - 但这是值得的),并且它还允许你在一个很优雅的图表中看到测试过程的输出,这个图表可以让你了解代码质量(例如,也许在一个类中有太多的 if - 这会被标记为质量差,因为有这么多的 if 就会导致在改变一个条件时需要重写很多测试代码)。

闲话少说,学习走起~

本教程的代码可以在 这里 下载。

开启一个事例应用

在这个例子中,我们将创建一个简单的命令行程序包,它把JSON文件转换成PHP文件,用PHP的关联数组表示JSON数据.这其实是我日常用到的一个事例,我使用 Diffbot 很频繁,而且这些东西的输出有时候会非常之多以至于没办法人工观察处理,所以用PHP简单处理一下就解决这个问题可以说很舒服了.

从现在开始, 默认你在使用一个完全支持PHP7的环境并且安装了 Composer ,这就足够了.另外如果你在使用 Homestead Improved, 那直接vagrant sshSSH进去, 现在咱们就开始吧。

第一步,进入项目目录.这个演示的例子使用 Homestead Improved,项目目录是Code.

cd Code

然后,我们在 PDS-Skeleton 的基础上创建一个新项目,并且在它里面使用 Composer安装PHPUnit.

git clone https://github.com/php-pds/skeleton converter
cd converter
composer require phpunit/phpunit --dev

注意,我们使用了 --dev 标识,这样可以只把PHPUnit安装成开发的依赖,在发布时不会包含在内,这样可以让发布的项目更加精简.另外还要注意,因为使用了PDS-Skeleton所以它已经创建了一个test文件夹,里面还有两个对我们没用的待删除的demo文件.

接下来,我们的应用需要一个前端控制器 -- 这个文件的所有请求都通过路由访问。在 converter/public 文件夹下,创建 index.php,内容如下:

<?php
echo "Hello world";

你应该很熟悉这段代码。使用浏览器打开这个文件,确保可以正常访问。

如果你使用的是 Homestead,希望你可以创建一个虚拟主机,并使用虚拟主机的 IP 访问你的应用。

3CbbSfFYuC.png

现在让我们删除额外的文件。你可以手动删除,也可以使用下面的命令:

rm bin/* src/* docs/* tests/*

你可能会问,为什么我们需要 Hello World 这个前端控制器?其实我们在本教程中并不会用到它,但稍后我们在测试应用的时候,它会很有用。所以不管怎样,最终它不是我们这个包的一部分。

套件 & 配置

我们需要一个配置文件来告诉 PHPUnit 去哪找到测试、在测试之前要做哪些准备,以及如何测试。在项目根目录下,创建 phpunit.xml 文件,内容如下:

<phpunit bootstrap="tests/autoload.php">
  <testsuites>
    <testsuite name="converter">
      <directory suffix="Test.php">tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

phpunit.xml

一个测试可以有多个测试套件,因业务逻辑而异。比如说,任何跟用户相关的内容都可以归类到 “users” 套件中,这可能就需要不同的测试逻辑或者存放在不同的文件夹中来测试这些功能。在我们这个示例中,项目很小,针对 tests 目录,一个套件绰绰有余。我们定义了 suffix 参数,这就意味着 PHPUnit 将只运行那些以 Test.php 结尾的文件。当我们还想在 tests 中创建其他文件时,除了从实际的 Test 文件中调用它们之外,不希望它们运行,这时这个 suffix 参数就很有用了。

你可以在 这里 了解其他关于这方面的内容。

bootstrap 的值告诉 PHPUnit 在测试之前应该加载哪个 PHP 文件。这对于配置自动加载、在项目范围内测试变量,甚至是测试数据库等等(所有你在生产环境下不想要或者不需要的东西)一系列功能都是非常有用的。现在创建 tests/autoload.php 文件:

<?php

require_once __DIR__.'/../vendor/autoload.php';

tests/autoload.php

在这个例子中,我们只加载 Composer 的默认自动加载器,因为 PDS-Skeleton 已经在 composer.json 中为我们配置了测试的命名空间。如果我们用自己的文件替换了该文件中的模板值,那么最终的 composer.json 文件应该是这样的:

{
    "name": "sitepoint/jsonconverter",
    "type": "standard",
    "description": "A converter from JSON files to PHP array files.",
    "homepage": "https://github.com/php-pds/skeleton",
    "license": "MIT",
    "autoload": {
        "psr-4": {
            "SitePoint\\": "src/SitePoint"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "SitePoint\\": "tests/SitePoint"
        }
    },
    "bin": ["bin/converter"],
    "require-dev": {
        "phpunit/phpunit": "^6.2"
    }
}

现在,我们运行 composer du (简写  dump-autoload)来刷新自动加载脚本。

composer du

第一个测试

请记住,TDD 是先制作错误再解决错误的艺术,顺序不要颠倒。牢记这一点,咱们就开始创建第一个测试吧。

<?php

namespace SitePoint\Converter;

use PHPUnit\Framework\TestCase;

class ConverterTest extends TestCase {

    public function testHello() {
        $this->assertEquals('Hello', 'Hell' . 'o');
    }

}

tests/SitePoint/Converter/ConverterTest.php

最好让测试的结构和我们期望的项目结构保持一致。因为这一点,我们让它们有相同的命名空间和文件结构, 所以,ConverterTest.php 会在 tests,子目录 SitePoint,子目录 Converter 里面。

我们扩展的是 PHPUnit 提供的最基础的 Test 类。大多数情况下这样就够用了。当不能满足的时候,进行更多的扩展也是完全 OK 的。 记住 —— 测试代码不需要遵循那些优秀的软件的设计原则,所以深度继承和代码重复也还是可以的 —— 当然前提是这些测试代码真的测了那些需要测试的东西!
这个例子 "test case" 断言 字符串 Hello 是由 Hell 和 o 相连,如果我们运行 php vendor/bin/phpunit ,我们就会得到这个通过的结果。

file

PHPUnit 默认会执行所有 Test 文件中方法名以 test 开头的方法,这就是为什么我们在运行测试工具时不需要明确的去指定 - 因为这是全自动的。

不过,我们目前的测试既不实用也不现实。我们只是用它来检查我们的配置是否成功。我们现在写一个合适的。像这样重写 ConverterTest.php

<?php

namespace SitePoint\Converter;
use PHPUnit\Framework\TestCase;

class ConverterTest extends TestCase
{

    public function testSimpleConversion()
    {
        $input = '{"key":"value","key2":"value2"}';
        $output = [
            'key' => 'value',
            'key2' => 'value2'
        ];
        $converter = new \SitePoint\Converter\Converter();
        $this->assertEquals($output, $converter->convertString($input));
    }
}

tests/SitePoint/Converter/ConverterTest.php

好了,所以这里发生了什么?

我们正在测试一个简单的转换,输入一个JSON字符串,并期望它输出的是PHP数组,我们的测试断言我们的转换器类,在使用conversionstring方法处理$ input时,会产生所需的$ output,就像上面定义的那样。

再运行一遍测试工具。

eRbx9mrcQR.png

测试失败,就像期望的一样,因为这个类还不存在。

让我们的测试看起来更好看些 -- 加入颜色! 修改 phpunit.xml 使得 <phpunit 标签包含 colors="true" 属性,就像这样:

<phpunit colors="true" bootstrap="tests/autoload.php">

现在当我们运行 php vendor/bin/phpunit, 我们会得到一个更好看的输出:

qcgf933j5V.png

让测试通过

现在我们开始进行让测试通过的操作。

我们的第一个错误是 “Class 'SitePoint\Converter\Converter' not found”。让我们来修正它。

<?php

namespace SitePoint\Converter;

class Converter
{

}

src/SitePoint/Converter/Converter.php;

现在我们重新运行测试...

nx73udptRn.png

有进展!我们现在调用了不存在的方法。让我们把它加进去。

<?php

namespace SitePoint\Converter;

class Converter
{
    public function convertString(string $input): ?array
    {

    }
}

src/SitePoint/Converter/Converter.php;

我们定义了一个接受字符串类型输入,返回数组或者在不成功时返回空 (null) 的方法。如果你不知道什么是标量类型(string $input),点击这里去学习一下,here, 还有可为空返回类型 (?array),可以看这里 here.

再运行一下测试。

zutM9X4yNO.png

这里有个返回错误 -- 方法没有返回任何东西 (void) -- 因为它里面是空的 -- 而且它的期望是返回空 (null) 或者数组。让我们来完善这个方法。我们将使用 PHP 的内置方法 json_decode 来完成对 JSON 字符串的转码。

    public function convertString(string $input): ?array
    {
        $output = json_decode($input);
        return $output;
    }

src/SitePoint/Converter/Converter.php;

我们再次运行测试看看会发生什么

h2HPXuh4o5.png

Oh!这个方法返回了一个对象,而并非一个数组。啊哈!因为我们没有在 json_decode 方法里开启 "associative array" 模式。这个方法模式将 JSON 数组转换为 stdClass 对象,除非我们另行说明。像这样:

    public function convertString(string $input): ?array
    {
        $output = json_decode($input, true);
        return $output;
    }

src/SitePoint/Converter/Converter.php;

接着运行测试。

WbuBw2PmWs.png

很好!我们的测试通过了!它在测试中获得了和我们所期望的完全一致的输出!

我们再多加几个测试用例,以确保我们的方法真正按预期执行。让我们比开始的简单例子做得更复杂点。那么我继续写方法 ConverterTest.php

    {
        $input     = '{"key":"value","key2":"value2","some-array":[1,2,3,4,5]}';
        $output    = [
            'key'        => 'value',
            'key2'       => 'value2',
            'some-array' => [1, 2, 3, 4, 5],
        ];
        $converter = new \SitePoint\Converter\Converter();
        $this->assertEquals($output, $converter->convertString($input));
    }

    public function testMoreComplexConversion()
    {
        $input     = '{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}';
        $output    = [
            'key'        => 'value',
            'key2'       => 'value2',
            'some-array' => [1, 2, 3, 4, 5],
            'new-object' => [
                'key'  => 'value',
                'key2' => 'value2',
            ],
        ];
        $converter = new \SitePoint\Converter\Converter();
        $this->assertEquals($output, $converter->convertString($input));
    }

    public function testMostComplexConversion()
    {
        $input     = '[{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}]';
        $output    = [
            [
                'key'        => 'value',
                'key2'       => 'value2',
                'some-array' => [1, 2, 3, 4, 5],
                'new-object' => [
                    'key'  => 'value',
                    'key2' => 'value2',
                ],
            ],
            [
                'key'        => 'value',
                'key2'       => 'value2',
                'some-array' => [1, 2, 3, 4, 5],
                'new-object' => [
                    'key'  => 'value',
                    'key2' => 'value2',
                ],
            ],
            [
                'key'        => 'value',
                'key2'       => 'value2',
                'some-array' => [1, 2, 3, 4, 5],
                'new-object' => [
                    'key'  => 'value',
                    'key2' => 'value2',
                ],
            ],
        ];
        $converter = new \SitePoint\Converter\Converter();
        $this->assertEquals($output, $converter->convertString($input));
    }

tests/SitePoint/Converter/ConverterTest.php

我们让每个测试用例都比以前的复杂了点,尤其最后一个用例在一个数组中包含了多个对象。重新跑一下这个测试集合,以展示一切正常……

2Rf6lcjOZ2.png

... 不过似乎有什么不对劲对不对? 这里存在大量重复代码,如果我们要修改该类的 API,就必须要修改四个地方(就目前代码而言)。DRY原则的优势就体现出来,即使是测试中。恰好有个功能可以处理此事。

数据提供器

数据提供器在测试类中是很特殊的函数,它只有一个明确目标:给测试函数提供一系列的数据,以避免在多个测试函数中重复相同逻辑,就象前面做的。最好还是在示例中解释一下,我们对类 ConverterTest 进行重构:

<?php

namespace SitePoint\Converter;

use PHPUnit\Framework\TestCase;

class ConverterTest extends TestCase
{

    public function conversionSuccessfulProvider()
    {
        return [
            [
                '{"key":"value","key2":"value2"}',
                [
                    'key'  => 'value',
                    'key2' => 'value2',
                ],
            ],

            [
                '{"key":"value","key2":"value2","some-array":[1,2,3,4,5]}',
                [
                    'key'        => 'value',
                    'key2'       => 'value2',
                    'some-array' => [1, 2, 3, 4, 5],
                ],
            ],

            [
                '{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}',
                [
                    'key'        => 'value',
                    'key2'       => 'value2',
                    'some-array' => [1, 2, 3, 4, 5],
                    'new-object' => [
                        'key'  => 'value',
                        'key2' => 'value2',
                    ],
                ],
            ],

            [
                '[{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}]',
                [
                    [
                        'key'        => 'value',
                        'key2'       => 'value2',
                        'some-array' => [1, 2, 3, 4, 5],
                        'new-object' => [
                            'key'  => 'value',
                            'key2' => 'value2',
                        ],
                    ],
                    [
                        'key'        => 'value',
                        'key2'       => 'value2',
                        'some-array' => [1, 2, 3, 4, 5],
                        'new-object' => [
                            'key'  => 'value',
                            'key2' => 'value2',
                        ],
                    ],
                    [
                        'key'        => 'value',
                        'key2'       => 'value2',
                        'some-array' => [1, 2, 3, 4, 5],
                        'new-object' => [
                            'key'  => 'value',
                            'key2' => 'value2',
                        ],
                    ],
                ],
            ],

        ];
    }

    /**
     * @param $input
     * @param $output
     * @dataProvider conversionSuccessfulProvider
     */
    public function testStringConversionSuccess($input, $output)
    {
        $converter = new \SitePoint\Converter\Converter();
        $this->assertEquals($output, $converter->convertString($input));
    }

}

tests/SitePoint/Converter/ConverterTest.php

我先写了个命名为 conversionSuccessfulProvider 的新方法。这暗示这样的一种预期:所有提供的用例应该返回正面的结果,因为输出和输入相匹配。数据提供器返回数组(以便测试函数能自动遍历所有元素)。该数组的每个元素都是单独的测试用例 —— 我们的用例中,每个元素是个包含两个元素的数组:前者是输入元素,后者是输出元素,和前面的代码类似。

我们把这些测试功能合并到一个方法中,给该方法起一个更通用、更有所指何物的明确性的名称: testStringConversionSuccess。该测试方法接受两个参数: input 和 output。 其余逻辑与以前一致。 此外,为确保该方法使用了这个数据提供器,我们要在该方法的 docblock 块区中用 @dataProvider conversionSuccessfulProvider 声明该提供器。

大功造成 —— 现在我们可以得到完全相同的结果。

4Wol7tr8m0.png

现在再想添加更多的测试用例,仅需要给提供器多添加些 input-output 值对即可。没必要发明新的方法名重复那些逻辑了。方便多了,对吧?

代码覆盖率的介绍

在我们看这个部分之前,让我们来吸收迄今为止所介绍的所有内容,让我们简单地讨论代码覆盖率。

代码覆盖率是一个度量标准,告诉我们有多少代码被测试覆盖。
如果我们的类有两个方法,但是只有一个在测试中被测试过,那么我们的代码覆盖率至多是50% - 取决于方法有多少个逻辑分支(if,switch,loop等)每个分支都应该有一个单独的测试覆盖)。
phpunit能够在运行给定的测试工具后自动生成代码覆盖率报告。

让我们来快速配置一下,我们会在 phpunit.xml 的 <phpunit> 里面添加 <logging> 和 <filter> ,作为1级子元素 (如果 <phpunit> 是0级或者根级元素的话):

<phpunit ...>
    <filter>
        <whitelist>
            <directory suffix=".php">src/</directory>
        </whitelist>
    </filter>
    <logging>
        <log type="tap" target="tests/build/report.tap"/>
        <log type="junit" target="tests/build/report.junit.xml"/>
        <log type="coverage-html" target="tests/build/coverage" charset="UTF-8" yui="true" highlight="true"/>
        <log type="coverage-text" target="tests/build/coverage.txt"/>
        <log type="coverage-clover" target="tests/build/logs/clover.xml"/>
    </logging>

在过滤器中设置一个白名单,告诉phpunit在测试时需要注意哪些文件。这会编译成 / src里的所有.php文件,在任何级别
日志记录会告诉phpunit要生成哪些报告 - 不同工具可以生成不同报告,因此生成更多格式的报告并不会造成什么影响。
在我们的例子中,我们只是对html作探讨

在这个可以工作之前,我们需要启用XDebug,因为这是PHPUnit 所需要的PHP扩展,Homestead Improved phpenmod 工具可以在运行中启用或停用 PHP 扩展:

sudo phpenmod xdebug

如果你没有使用 HI(Homestead Improved),请遵循XDebug对应的相关系统的安装方法,这篇文章 应该有所帮助。

重新运行该工具我们应该就能看到生成的覆盖率报告。另外,它们也会生成在指定位置的目录树中。

XCfFYhVIKb.png

file

让我们现在用浏览器打开 index.html ,直接把它拖到浏览器中应该就好了 -- 不需要再启动服务器什么的 -- 因为这只是个静态文件。

这个文件将列出所有测试的摘要。你可以点击进入单独的类来查看详细的覆盖率报告,悬停在方法上会弹出给定方法测试的工具提示。

dattCycHhI.pngg1etvujIo6.png

Tooltip over the convertString method

随着我们进一步开发我们的工具,我们将在后续文章中深入探讨代码覆盖率。

结论

在这个PHPUnit的介绍中,我们看到了测试驱动开发(TDD) 的基本概念。接触到了一个PHP工具开始阶段的观念。所有的代码可以从Github下载下来。


我们透过PHPUnit基础,解释数据的提供者,展示了代码覆盖率。这个帖子仅仅接触到了PHPUnit的一些基本概念和特性。我们鼓励你进一步探索。或者你提出你理解不了的需要解释的概念,我们希望能给你解释清楚。


在后面的文章中,我们将介绍一些中间技术, 进一步开发我们的应用。


请在下面留下你的评论和问题!


Practice makes perfect.

原文地址:https://www.sitepoint.com/re-introducing...

译文地址:https://laravel-china.org/topics/7911/re...

本帖已被设为精华帖!
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 3

只能说这篇文章太长了,都不愿翻译了

8个月前
Summer

@Kevinvinvin 很快会被翻译完的 ? ,周末人比较少,你已经贡献够多了 ?

8个月前

@ollbao 辛苦了大兄弟:grinning:

8个月前

  • 请注意单词拼写,以及中英文排版,参考此页
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`, 更多语法请见这里 Markdown 语法
  • 支持表情,使用方法请见 Emoji 自动补全来咯,可用的 Emoji 请见 :metal: :point_right: Emoji 列表 :star: :sparkles:
  • 上传图片, 支持拖拽和剪切板黏贴上传, 格式限制 - jpg, png, gif
  • 发布框支持本地存储功能,会在内容变更时保存,「提交」按钮点击时清空
  请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!