【译】现代化的PHP开发--TDD

2019-10-29 18:17:54 浏览数 (2)

来源/https://www.startutorial.com/articles/view/modern-php-developer-tdd 译/Lemon黄

TDD 简介

如果你还没有听说过测试驱动开发(TDD),应该开始熟悉它了。尽管与Ruby之类的其他语言相比,PHP社区在TDD实施方面稍晚一些。但是,一旦实现了TDD的优势,对于现代PHP开发人员来说,它几乎就变得至关重要。

TDD是一种软件开发技术。TDD的基本思想是,在实际编写任何代码之前先进行测试。在没有代码的情况下编写测试,比其他任何事情更能带来思维转变。这与传统的编码习惯相反,在传统的编码习惯中,我们首先创建代码,然后手动运行该单元以确保其达到了我们期望的目的。TDD给我们带来的好处是巨大的。首先,它迫使我们在创建任何具体代码之前考虑代码设计,然后使我们能够重构代码库而不必担心会有副作用。从长远来看,它使我们的代码易于维护。

TDD由三个阶段组成:红灯、绿灯、重构 :明确了实施TDD所要遵循的工作流 (需求--->测试-->代码[重构])

红灯阶段:

在红灯阶段,作为开发人员,我们将计划代码的草稿,而无需实际编写。也就是说,我们将设计我们的类或类方法,而不实现其细节。最初,此阶段很困难,它要求我们改变传统的编码习惯。但是一旦习惯了这个过程,我们自然会适应它并意识到它可以帮助我们设计更好的代码。这是关于改变思维方式的,因为我们应该专注于API的输入和输出,而不是代码的细节。此阶段的结果是成功创建了红色测试。

绿灯阶段:

在绿灯阶段,这就是编写最快的代码以通过测试的全部。在这个阶段,我们不应该花费太多时间使代码简洁或重构。尽管我们所有人都想编写最漂亮的代码,但这并不是当前阶段的任务。此阶段的结果是绿色测试。

重构阶段:

在重构阶段,我们专注于使代码简洁。由于我们已经在上面创建了可以防止bug产生副作用的测试,因此我们对执行重构抱有信心。如果偶然地从重构中引入了一个错误,我们的测试将在其出现后立即报告它。因此,重构是在修改任何代码后立即运行测试的自然方法。

TDD 单元测试

TDD使我们可以测试驱动开发周期。在PHP中使用TDD时,显然,我们需要定义将要进行的测试类型。TDD中最常见的测试是单元测试,是单元的应用程序中最小的可测试部分,通常表示出来的就是一种类方法。

现在想象一下手动编写单元测试并构建一种自动方法来运行它们,这肯定是需要处理很多工作才能完成。幸运的是,已经有单元测试框架供我们使用。在许多单元测试框架中,PHPUnit是最流行的框架,并且已在PHP社区中广泛使用。

PHPUnit的入门

1、安装:

安装PHPUnit的最简单方法是通过Composer。 进入项目文件夹中并运行终端,只需运行如下代码即可。

代码语言:javascript复制
composer require phpunit / phpunit

默认情况下,PHPUnit的bin文件将放置在vendor / bin文件夹中,因此我们可以直接从项目的根文件夹中运行vendor / bin / phpunit。

2、单元测试尝试

是时候创建你的第一个单元测试了!在做之前,我们需要一个类进行测试。让我们创建一个非常简单的计算器类,并为其编写测试。

创建一个名为Calculator.php的文件,并将下面的代码复制到该文件中。该Calculator类仅具有add函数:

代码语言:javascript复制
class Calculator
{
    public function add($a, $b)
{
        return $a   $b;
    }
}

创建测试文件CalculatorTest.php,然后将以下代码复制到该文件中。我们将详细解释每个功能:

代码语言:javascript复制
<?php
require 'Calculator.php';
class CalculatorTest extends PHPUnit_Framework_TestCase
{
private $calculator;

protected function setUp()
{
$this->calculator = new Calculator();
    }

protected function tearDown()
{
$this->calculator = NULL;
    }

public function testAdd()
{
        $result = $this->calculator->add(1, 2);
$this->assertEquals(3, $result);
    }
}
?>
  • 行2:包含类文件Calculator.php。这是我们要测试的类,因此要确保将其包括在内。
  • 行7:在每次测试运行之前调用setUp()。请记住,它在每次测试之前运行,这意味着,如果您有另一个测试函数,它也将在进行测试之前运行setUp()。
  • 行12:类似于setUp(),在每次测试完成后将调用tearDown()。
  • 行17:testAdd()是add()函数的测试函数。PHPUnit会将带有test前缀的所有功能识别为测试功能,并自动运行它们。这个函数实际上非常简单:我们首先调用Calculator.add函数来计算1加2的值。然后使用PHPUnit函数assertEquals检查它是否返回正确的值。

任务的最后一部分是运行PHPUnit,并确保它通过所有测试。 进入到创建测试文件的文件夹内,然后打开终端运行以下命令:

代码语言:javascript复制
vendor/bin/phpunit CalculatorTest.php

可以看到显示了如下所示的成功消息:

代码语言:javascript复制
PHPUnit 5.0.9 by Sebastian Bergmann and contributors.
.                           1 / 1 (100%)

Time: 40 ms, Memory: 2.50Mb

3、数据提供者

编写函数时,我们要确保函数中传递一系列边缘情况(例如被除数不能为0)。测试同样如此。这意味着我们需要编写多个测试以使用不同的数据集来测试同一功能。例如,如果我们想使用不同的数据来测试我们的Calculator类,而没有数据提供者,那么我们将有多个测试,如下所示:

代码语言:javascript复制
<?php
require 'Calculator.php';
class CalculatorTest extends PHPUnit_Framework_TestCase
{
private $calculator;

protected function setUp()
{
$this->calculator = new Calculator();
    }

protected function tearDown()
{
$this->calculator = NULL;
    }

public function testAdd()
{
        $result = $this->calculator->add(1, 2);
$this->assertEquals(3, $result);
    }

public function testAddWithZero()
{
        $result = $this->calculator->add(0, 0);
$this->assertEquals(0, $result);
    }

public function testAddWithNegative()
{
        $result = $this->calculator->add(-1, -1);
$this->assertEquals(-2, $result);
    }
}
?>

在这种情况下,我们可以使用PHPUnit中的数据提供者(data provider)功能来避免测试中的重复。

3.1、怎样使用数据提供者(data provider)

数据提供者方法返回实现Iterator接口的各种数组或对象。 然后使用数组的内容作为参数调用测试方法。

使用数据提供者时要记住如下的几个关键点:

  • 数据提供者方法必须是public方法
  • 数据提供者返回收集数据的数组
  • 测试方法使用注解(@dataProvider)声明来声明是数据提供者方法。

一旦知道了要点,使用数据提供程序实际上就非常简单。首先,我们创建一个新的public方法,该方法返回一个集合数据的数组作为test方法的参数,然后在test方法中添加注释以告知PHPUnit哪个方法将提供参数。

添加数据提供者方法到我们的例子中,如下所示:

代码语言:javascript复制
<?php
require 'Calculator.php';
class CalculatorTest extends PHPUnit_Framework_TestCase
{
private $calculator;

protected function setUp()
{
$this->calculator = new Calculator();
    }

protected function tearDown()
{
$this->calculator = NULL;
    }

public function addDataProvider()
{
return array(
array(1,2,3),
array(0,0,0),
array(-1,-1,-2),
        );
    }

/**
     * @dataProvider addDataProvider
     */
public function testAdd($a, $b, $expected)
{
        $result = $this->calculator->add($a, $b);
$this->assertEquals($expected, $result);
    }
}
?>
  • 第19行:添加数据提供程序方法。请注意,必须将数据提供程序方法声明为public方法。
  • 第27行:使用注解(@dataProvider addDataProvider)声明测试方法的数据提供者方法。

现在,再次运行我们的测试,它应该通过了。如你所见,我们利用数据提供程序来避免代码重复。现在,我们只有一个测试方法,而不是为基本相同的方法编写三种测试方法。

4、双重测试

4.1、何时使用双重测试

如本系列第一部分所述。PHPUnit的强大功能之一是双重测试。在我们的代码中,一个类的方法调用另一个类的方法是很常见的。在这种情况下,这两个类之间存在依赖关系。特别是,调用者类对调用类有依赖性,但是正如我们从第1部分中已经知道的那样,单元测试应该测试最小的功能单元。在这种情况下,它应该仅测试调用者功能。为了解决这个问题,我们可以使用test double代替调用类。由于可以将双重测试配置为返回预定义的结果,因此我们可以集中精力测试调用者函数。

4.2、双重测试的类型:

双重测试是我们使用的对象的通用术语,用来代替实际生产的就绪对象。根据我们的经验,按测试目的对双重测试进行分类非常有用。 这不仅使我们易于理解测试用例,而且使我们的代码对其他方友好。

根据马丁·福勒(Martin Fowler)的文章,有五种类型的双重测试:

  • Dummy 对象会传递,但从未实际使用过。通常它们仅用于填充参数列表。
  • Fake 对象实际上具有有效的实现,但通常采用一些快捷方式,这使其不适用于生产。
  • Stubs 提供对测试过程中进行的呼叫的固定答复,通常根本不响应为测试编程的内容。
  • Spies 是stubs ,它们还会根据调用方式记录一些信息。 其中一种形式可能是电子邮件服务,它记录发送了多少消息。Mocks是预先编程的,期望值构成了期望收到的呼叫的规范。 如果收到不希望的呼叫,并且可以在验证过程中进行检查以确保收到了所有期望的电话,则可以引发异常。

4.3、如何创建双重测试

PHPUnit的方法getMockBuilder可用于创建任何类似的用户定义对象。结合其可配置的界面,我们可以使用它来创建以上所有五种类型的双重测试。

用之前的例子我们来添加双重测试

在我们的计算器测试用例中使用测试倍数是没有意义的,因为当前Calculator类不依赖于其他类,但是,为了演示如何在PHPUnit中使用测试倍数,我们将创建一个Stub的Calculator类并对其进行测试。

让我们在现有类中添加一个名为testWithStub的测试用例:

代码语言:javascript复制
public function testWithStub()
{
// Create a stub for the Calculator class.
    $calculator = $this->getMockBuilder('Calculator')
                       ->getMock();

// Configure the stub.
    $calculator->expects($this->any())
               ->method('add')
               ->will($this->returnValue(6));

$this->assertEquals(6, $calculator->add(100,100));
}
  • getMockBuilder():创建一个类似于我们的Calculator对象的Stub。
  • getMock():返回对象。
  • expects():告诉Stub被调用任意次。
  • method():指定将调用哪个方法。
  • will():配置Stub的返回值。

我们介绍了PHPUnit的一些基本用法,它提供了创建单元测试所需的几乎所有功能。 你应该始终根据需要尝试从其官方手册中找到更多信息。

TDD应用例子

在本节中,我们将通过一个非常简单的示例来演示TDD背后的过程。 在此示例中,你应集中精力处理TDD的三个阶段。

假设我们承担了为我们的电子商务系统构建价格计算器的任务。我们将要开发的类将是PriceCalculator。让我们首先设置项目的文件夹和文件结构及其依赖项。

与往常一样,我们将使用Composer作为包管理器,并使用PSR-4作为我们的代码标准。 唯一的第三方依赖性是PHPUnit。 为了进行设置,我们将创建一个用于放置源文件的文件夹src,以及一个用于放置测试文件的文件夹测试。 我们还将分别创建src / PriceCalculator.php和tests / PriceCalculatorTest.php。 最后,我们将创建一个composer.json文件,如下所示:

代码语言:javascript复制
{
"require": {
"phpunit/phpunit": "^5.0"
    },
"autoload": {
"psr-4": {
"Dilab\Order\": "src"
        }
    }
}

该文件告诉Composer下载PHPUnit,并告诉自动加载器我们的源代码遵循PRS-4标准。

通过运行命令:composer install,我们应该最终得到一个文件夹结构,如下所示:

代码语言:javascript复制
.
 -- src
|    -- PriceCalculator.php
 -- tests
|    -- PriceCalculatorTest.php
 -- vendor
|    -- dependency-1
|    -- dependency-2
|    -- dependency-3
|    -- dependency-xxx
 -- composer.json
 -- composer.lock

我们需要设置的最后一部分是用于配置PHPUnit的phpunit.xml文件。 让我们在项目的根目录中创建它:

代码语言:javascript复制
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false"
         syntaxCheck="false"<
    <testsuites>
        <testsuite name="Test Suite">
            <directory suffix=".php">./tests/</directory>
        </testsuite>
    </testsuites>
</phpunit>

我们最终的文件夹结构应如下所示:

代码语言:javascript复制
.
 -- src
|    -- PriceCalculator.php
 -- tests
|    -- PriceCalculatorTest.php
 -- vendor
|    -- dependency-1
|    -- dependency-2
|    -- dependency-3
|    -- dependency-xxx
 -- composer.json
 -- composer.lock
 -- phpunit.xml

1、红灯阶段

在此阶段,我们将计划API的外观并创建失败的测试。 在此示例中,所需的API方法非常简单。 我们只需要一个接受数组作为其参数并计算总价的方法。 我们将这种方法命名为total。

在编写任何源代码之前,让我们在tests / PriceCalculatorTest.php文件中创建一些测试:

代码语言:javascript复制
<?php
namespace DilabOrderTest;
use DilabOrderPriceCalculator;

class PriceCalculatorTest extends PHPUnit_Framework_TestCase
{
private $PriceCalculator;

public function setUp()
{
parent::setUp();
$this->PriceCalculator = new PriceCalculator();
    }

public function tearDown()
{
parent::tearDown();
unset($this->PriceCalculator);
    }

/**
    * @test
    */
public function object_can_created()
{
        $priceCalculator = new PriceCalculator();
$this->assertInstanceOf('DilabOrderPriceCalculator', $priceCalculator);
    }

/**
    * @test
    */
public function should_sum_price()
{
        $items = [
            ['price' => 100],
            ['price' => 200],
        ];

        $result = $this->PriceCalculator->total($items);
$this->assertEquals(300, $result);
    }

/**
    * @test
    */
public function empty_items_should_return_zero()
{
        $items = [];
        $result = $this->PriceCalculator->total($items);
$this->assertEquals(0, $result);
    }
}
?>

我们为PriceCalculator创建了三个测试:

  • public function object_can_created():此测试确保可以实例化该对象。 有人可能会认为这是不必要的,但是从TDD的角度来看,我们希望进行这种简单的测试。 通过此测试后,我们自然可以继续测试其实际行为。
  • public function should_sum_price() :此方法测试total方法是否按其说明进行工作。
  • public function empty_items_should_return_zero():此方法测试边缘情况,该情况下订单中没有项目。 在这种情况下,total方法应返回零。

现在,如果我们从终端运行vendor / bin / phpunit,则应该得到如下所示的错误:

代码语言:javascript复制
Fatal error: Class 'DilabOrderPriceCalculator' not found in tests/PriceCalculatorTest.php

2、绿灯阶段

此阶段的任务是使最失败的测试通过最简单但不一定是最佳的代码通过。 此阶段的最终目标是绿色信息。

实现起来很容易。我们需要做的就是用foreach循环将值求和:

代码语言:javascript复制
namespace DilabOrder;
class PriceCalculator
{
public function total($items)
{
        $total = 0;
foreach ($items as $item) {
            $total  = $item['price'];
        }
return $total;
    }
}

现在,如果我们从终端运行vendor / bin / phpunit,我们将获得绿色消息,如下所示:

代码语言:javascript复制
PHPUnit 5.09 by Sebastian Bergmann and contributors.

...             3 / 3 (100%)

Time: 78 ms, Memory: 2.75Mb

3、重构阶段

这是TDD的最后阶段,我们认为这是TDD最有价值的部分。 在这个阶段,我们将看一下我们先前编写的代码,并思考使它变得更简介,更好的方法。

我们在total方法中使用了foreach循环。它遍历$ items数组并返回每个元素的总和。这实际上是array_reduce方法的完美用例。函数array_reduce使用回调函数将数组迭代地减少为单个值。让我们通过用array_reduce替换foreach循环来重构代码。

代码语言:javascript复制
public function total($items)
{
    return array_reduce($items, function ($carry, $item) {
       return $carry   $item['price'];
    }, 0);
}

如果我们再次运行测试,它们仍然通过。我们需要不断运行测试以确保重构不会破坏任何东西,所以保持我们的代码重构质量很重要。

我们已经将代码从五行清除为两行。 没有更多临时变量。 该方法变得更易于调试。 在此示例中这样做可能没有明显的好处,但是可以想象在一个大型项目中这样做,即使清理一行代码也可能使开发变得更容易。

TDD到此结束。 再次强调,TDD的精神是让测试推动我们的发展。 在项目中使用PHPUnit不一定会使它成为TDD驱动的项目。 开发涉及TDD的过程涉及就是以上的三个阶段。

0 人点赞