写单元测试
让我们开始深入了解 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
的断言。这些方法就像 assertTrue
和 assertFalse
一样简单,它们只需要一个参数,即要断言的值,以及在断言失败时显示的文本。在同一个 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.'
);
第二类断言是比较断言。最有名的是 assertSame
和 assertEquals
。你已经使用过第一个断言,但你确定它的含义吗?让我们再添加一个测试并运行它:
public function testGetMonthlyFee() {
$this->assertSame(
5,
$this->customer->getMonthlyFee(),
'Basic customer should pay 5 a month.'
);
}
测试结果如下图所示:

测试失败!原因是 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.'
);
本次测试的结果如下图所示:

测试失败的原因是,使用身份比较法比较两个对象时,比较的是对象引用,只有当两个对象是完全相同的实例时,引用才会相同。如果创建了两个具有相同属性的对象,它们会相等,但绝不会相同。要修复该测试,请更改断言如下:
$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
域对象的测试套件是不够的。在这种情况下,使用 assertCount
和 assertArrayHasKey
会使测试变得不必要的冗长,因此我们只需通过以下代码将数组与预期数组进行比较:
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"(基本客户,小写)。这在测试失败时非常有用,因为它将作为错误信息的一部分显示出来。每个条目都是一个含有两个值(type
和 expectedType
)的映射,这两个值就是测试方法的参数名称。这些条目的值就是 test
方法将得到的值。
最重要的是,如果我们编写四次 testFactoryValidCustomerTypes
,每次都对 $type
和 $expectedType
进行硬编码,那么我们编写的代码将是一样的。试想一下,如果 test
方法包含几十行代码,或者我们想用几十个数据集重复同样的测试,你知道它有多强大了吗?