写单元测试

让我们开始深入了解 PHPUnit 为编写测试而提供的所有功能。我们将把这些功能分成不同的小节:设置测试、断言、异常和数据提供者。当然,每次编写测试时,你并不需要使用所有这些工具。

测试的开始和结束

PHPUnit 提供了为类中的每个测试设置通用场景的机会。为此,您需要使用 setUp 方法,如果该方法存在,则每次执行该类的测试时都会执行该方法。调用 setUp 方法和测试方法的类实例是相同的,因此可以使用类的属性来保存上下文。一个常用的方法是创建我们将用于测试的对象,以防该对象始终保持不变。例如,在 tests/Domain/Customer/BasicTest.php 中编写以下代码:

<?php

namespace Bookstore\Tests\Domain\Customer;
use Bookstore\Domain\Customer\Basic;
use PHPUnit_Framework_TestCase;

class BasicTest extends PHPUnit_Framework_TestCase {
    private $customer;

    public function setUp() {
        $this->customer = new Basic(
            1, 'han', 'solo', 'han@solo.com'
        );
    }

    public function testAmountToBorrow() {
        $this->assertSame(
            3,
            $this->customer->getAmountToBorrow(),
            'Basic customer should borrow up to 3 books.'
        );
    }
}

调用 testAmountToBorrow 时,已通过执行 setUp 方法初始化了 $customer 属性。如果该类有多个测试,则每次都会执行 setUp 方法。

还有一种方法在测试执行后用于清理场景:tearDown。该方法的工作原理相同,但它是在该类的每个测试执行完毕后执行的。可能的用途包括清理数据库数据、关闭连接、删除文件等。

断言

我们已经介绍过断言的概念,因此本节只列出最常见的断言。我们建议你访问官方文档 https://phpunit.de/manual/current/en/appendixes.assertions.html ,以获取完整的断言列表,因为文档内容非常丰富;不过说实话,你可能用不到其中的很多断言。

我们将看到的第一种断言类型是布尔断言,即检查值是 true 还是 false 的断言。这些方法就像 assertTrueassertFalse 一样简单,它们只需要一个参数,即要断言的值,以及在断言失败时显示的文本。在同一个 BasicTest 类中,添加以下测试:

public function testIsExemptOfTaxes() {
    $this->assertFalse(
        $this->customer->isExemptOfTaxes(),
        'Basic customer should be exempt of taxes.'
    );
}

该测试确保基本客户永远不会免税。请注意,我们可以通过编写下面的代码来实现相同的断言:

$this->assertSame(
    $this->customer->isExemptOfTaxes(),
    false,
    'Basic customer should be exempt of taxes.'
);

第二类断言是比较断言。最有名的是 assertSameassertEquals。你已经使用过第一个断言,但你确定它的含义吗?让我们再添加一个测试并运行它:

public function testGetMonthlyFee() {
    $this->assertSame(
        5,
        $this->customer->getMonthlyFee(),
        'Basic customer should pay 5 a month.'
    );
}

测试结果如下图所示:

image 2023 11 03 18 19 39 063

测试失败!原因是 assertSame 等同于使用标识进行比较,即不使用类型戏法。getMonthlyFee 方法的结果始终是浮点数,而我们将把它与整数进行比较,因此正如错误信息所告诉我们的那样,结果永远不会相同。将断言改为 assertEquals,使用等价比较,现在测试就能通过了。

在处理对象时,我们可以使用断言来检查给定对象是否是预期类的实例。这样做时,请记住发送类的全名,因为这是一个很常见的错误。最好使用 ::class 获取类名,例如 Basic::class。在 tests/Domain/Customer/CustomerFactoryTest.php 中添加以下测试:

<?php

namespace Bookstore\Tests\Domain\Customer;
use Bookstore\Domain\Customer\CustomerFactory;
use PHPUnit_Framework_TestCase;

class CustomerFactoryTest extends PHPUnit_Framework_TestCase {
    public function testFactoryBasic() {
        $customer = CustomerFactory::factory(
            'basic', 1, 'han', 'solo', 'han@solo.com'
        );
        $this->assertInstanceOf(
            Basic::class,
            $customer,
            'basic should create a Customer\Basic object.'
        );
    }
}

该测试使用 customer 工厂创建了一个客户。由于客户的类型是 basic,因此结果应该是 Basic 的实例,这就是我们使用 assertInstanceOf 测试的内容。第一个参数是预期类,第二个参数是我们要测试的对象,第三个参数是错误信息。该测试还有助于我们了解对象比较断言的行为。让我们按照预期创建一个基本的客户对象,并将其与工厂的结果进行比较。然后运行测试,如下所示:

$expectedBasicCustomer = new Basic(1, 'han', 'solo', 'han@solo.com');

$this->assertSame(
    $customer,
    $expectedBasicCustomer,
    'Customer object is not as expected.'
);

本次测试的结果如下图所示:

image 2023 11 03 18 39 19 303

测试失败的原因是,使用身份比较法比较两个对象时,比较的是对象引用,只有当两个对象是完全相同的实例时,引用才会相同。如果创建了两个具有相同属性的对象,它们会相等,但绝不会相同。要修复该测试,请更改断言如下:

$expectedBasicCustomer = new Basic(1, 'han', 'solo', 'han@solo.com');

$this->assertEquals(
    $customer,
    $expectedBasicCustomer,
    'Customer object is not as expected.'
);

现在让我们在 tests/Domain/SaleTest.php 中编写 sale 域对象的测试。该类非常容易测试,而且允许我们使用一些新的断言,如下所示:

<?php

namespace Bookstore\Tests\Domain\Customer;

use Bookstore\Domain\Sale;
use PHPUnit_Framework_TestCase;

class SaleTest extends PHPUnit_Framework_TestCase {
    public function testNewSaleHasNoBooks() {
        $sale = new Sale();

        $this->assertEmpty(
            $sale->getBooks(),
            'When new, sale should have no books.'
        );
    }

    public function testAddNewBook() {
        $sale = new Sale();
        $sale->addBook(123);

        $this->assertCount(
            1,
            $sale->getBooks(),
            'Number of books not valid.'
        );

        $this->assertArrayHasKey(
            123,
            $sale->getBooks(),
            'Book id could not be found in array.'
        );

        $this->assertSame(
            $sale->getBooks()[123],
            1,
            'When not specified, amount of books is 1.'
        );
    }
}

我们在这里添加了两个测试:一个是确保新 sale 实例的相关书籍列表为空。为此,我们使用了 assertEmpty 方法,该方法将数组作为参数,并断言数组为空。第二个测试是在销售中添加一本书,然后确保图书列表的内容正确。为此,我们将使用 assertCount 方法,该方法用于验证数组(即第二个参数)的元素数量是否与第一个参数相同。在这种情况下,我们希望图书列表只有一个条目。该测试的第二个断言是使用 assertArrayHasKey 方法验证图书数组是否包含一个特定的键,即图书的 ID,其中第一个参数是键,第二个参数是数组。最后,我们将使用已知的 assertSame 方法检查插入的图书数量是否为 1。

尽管这两个新的断言方法有时很有用,但最后一个测试中的所有三个断言都可以用一个 assertSame 方法代替,将整个图书数组与预期数组进行比较,如下所示:

$this->assertSame(
    [123 => 1],
    $sale->getBooks(),
    'Books array does not match.'
);

如果我们不测试该类在添加多本图书时的行为,那么针对 sale 域对象的测试套件是不够的。在这种情况下,使用 assertCountassertArrayHasKey 会使测试变得不必要的冗长,因此我们只需通过以下代码将数组与预期数组进行比较:

public function testAddMultipleBooks() {
$sale = new Sale();
$sale->addBook(123, 4);
$sale->addBook(456, 2);
$sale->addBook(456, 8);

$this->assertSame(
    [123 => 4, 456 => 10],
    $sale->getBooks(),
    'Books are not as expected.'
);
}

期待异常

有时,方法会在某些意想不到的用例中抛出异常。当这种情况发生时,你可以尝试在测试中捕获异常,或利用 PHPUnit 提供的另一个工具:预期异常。要将测试标记为预期异常,只需添加 @expectedException 注解,后面跟上异常的类全名即可。你还可以选择使用 @expectedExceptionMessage 来断言异常的信息。让我们在 CustomerFactoryTest 类中添加以下测试:

/**
 * @expectedException \InvalidArgumentException
 * @expectedExceptionMessage Wrong type.
 */
public function testCreatingWrongTypeOfCustomer() {
    $customer = CustomerFactory::factory(
        'deluxe', 1, 'han', 'solo', 'han@solo.com'
    );
}

在此测试中,我们将尝试用工厂创建一个豪华客户,但由于这种客户类型并不存在,我们将收到一个异常。预期异常的类型是 InvalidArgumentException,错误信息是 "错误类型"。如果运行测试,就会发现测试通过了。

如果我们定义了预期异常,而异常从未被抛出,测试就会失败;预期异常只是另一种类型的断言。要了解这种情况,请在测试中添加以下内容并运行它;您将会得到一个失败的结果,PHPUnit 会抱怨说它预期会出现异常,但却从未抛出:

/**
* @expectedException \InvalidArgumentException
*/
public function testCreatingCorrectCustomer() {
    $customer = CustomerFactory::factory(
        'basic', 1, 'han', 'solo', 'han@solo.com'
    );
}

数据提供者

回想一下测试的流程,大多数情况下,我们调用一个方法,输入一个信息,然后期待一个输出。为了涵盖所有边缘情况,我们自然会用一组输入和预期输出来重复相同的操作。PHPUnit 提供了这样的功能,从而消除了大量重复代码。这一功能被称为 数据提供

数据提供者是在测试类中定义的一个公共方法,它返回一个具有特定模式的数组。数组的每个条目代表一个测试,其中键是测试的名称,也可以使用数字键,值是测试需要的参数。测试将使用 @dataProvider 注解声明它需要一个数据提供者,在执行测试时,数据提供者将注入测试方法所需的参数。让我们举例说明。在 CustomerFactoryTest 类中编写以下两个方法:

public function providerFactoryValidCustomerTypes() {
    return [
        'Basic customer, lowercase' => [
            'type' => 'basic',
            'expectedType' => '\Bookstore\Domain\Customer\Basic'
        ],
        'Basic customer, uppercase' => [
            'type' => 'BASIC',
            'expectedType' => '\Bookstore\Domain\Customer\Basic'
        ],
        'Premium customer, lowercase' => [
            'type' => 'premium',
            'expectedType' => '\Bookstore\Domain\Customer\Premium'
        ],
        'Premium customer, uppercase' => [
            'type' => 'PREMIUM',
            'expectedType' => '\Bookstore\Domain\Customer\Premium'
        ]
    ];
}

/**
 * @dataProvider providerFactoryValidCustomerTypes
 * @param string $type
 * @param string $expectedType
 */
public function testFactoryValidCustomerTypes(
    string $type,
    string $expectedType
) {
    $customer = CustomerFactory::factory(
        $type, 1, 'han', 'solo', 'han@solo.com'
    );
    $this->assertInstanceOf(
        $expectedType,
        $customer,
        'Factory created the wrong type of customer.'
    );
}

这里的测试是 testFactoryValidCustomerTypes,它需要两个参数: $type$expectedType。测试使用这两个参数通过工厂创建一个客户,并验证结果的类型,我们已经通过硬编码类型完成了这一工作。测试还声明它需要 providerFactoryValidCustomerType 数据提供程序。该数据提供程序会返回一个包含四个条目的数组,这意味着测试将使用四组不同的参数执行四次。每个测试的名称是每个条目的关键字,例如 "Basic customer, lowercase"(基本客户,小写)。这在测试失败时非常有用,因为它将作为错误信息的一部分显示出来。每个条目都是一个含有两个值(typeexpectedType)的映射,这两个值就是测试方法的参数名称。这些条目的值就是 test 方法将得到的值。

最重要的是,如果我们编写四次 testFactoryValidCustomerTypes,每次都对 $type$expectedType 进行硬编码,那么我们编写的代码将是一样的。试想一下,如果 test 方法包含几十行代码,或者我们想用几十个数据集重复同样的测试,你知道它有多强大了吗?