phpunit 快速入门

phpunit 的官方文档对如何使用 phpunit 进行了详细的说明。本人在通读文档后进行了一些概要提升,
同时摘录了一些示例 phpunit-demo,便于以后
理解和查阅。

文档较为简洁,但是也涵盖了平时使用的基本用法,适合入门使用。

安装 phpunit

项目安装

1
composer require --dev phpunit/phpunit

使用 ./vendor/bin/phpunit

全局安装

1
composer global require --dev phpunit/phpunit

使用 phpunit

快速入门

基本格式

  • 测试类命名: 类名 + Test , eg FooClassTest
  • 测试方法命名: test + 方法名, eg testFoo

也可以使用注释 @test 来标注需要测试的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use PHPUnit\Framework\TestCase;

class SampleTest extends TestCase
{
public function testSomething()
{
$this->assertTrue(true, 'This should already work.');
}

/**
* @test
*/
public function something()
{
$this->assertTrue(true, 'This should already work.');
}
}

测试依赖(@depends)

有一些测试方法需要依赖于另一个测试方法的返回值,此时需要使用测试依赖。测试依赖
通过注释 @depends 来标记。

下列中, depends 方法的 return 值作为 testConsumer 的参数传入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
use PHPUnit\Framework\TestCase;

class MultipleDependenciesTest extends TestCase
{
public function testProducerFirst()
{
$this->assertTrue(true);
return 'first';
}

public function testProducerSecond()
{
$this->assertTrue(true);
return 'second';
}

/**
* @depends testProducerFirst
* @depends testProducerSecond
*/
public function testConsumer($a, $b)
{
$this->assertSame('first', $a);
$this->assertSame('second', $b);
}
}

数据提供器(@dataProvider)

在依赖中,所依赖函数的返回值作为参数传入测试函数。除此之外,我们也可以用数据提供器
来定义传入的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use PHPUnit\Framework\TestCase;

class DataTest extends TestCase
{
/**
* @dataProvider additionProvider
*/
public function testAdd($a, $b, $expected)
{
$this->assertSame($expected, $a + $b);
}

public function additionProvider()
{
return [
'adding zeros' => [0, 0, 0], // 0 + 0 = 0 pass
'zero plus one' => [0, 1, 1], // 0 + 1 = 1 pass
'one plus zero' => [1, 0, 1], // 1 + 0 = 1 pass
'one plus one' => [1, 1, 2], // 1 + 1 = 2 pass
];
}
}

测试异常(expectException)

需要在测试方法的开始处声明断言,然后执行语句。而不是调用后再声明

也可以通过注释来声明 @expectedException, @expectedExceptionCode,
@expectedExceptionMessage, @expectedExceptionMessageRegExp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use PHPUnit\Framework\TestCase;

class ExceptionTest extends TestCase
{
public function testException()
{
$this->expectException(\Exception::class);

throw new \Exception('test');
}

/**
* @throws \Exception
* @test
* @expectedException \Exception
*/
public function exceptionExpect()
{
throw new \Exception('test');
}
}

测试 PHP 错误

通过提前添加期望,来使测试正常进行,而不会报出 PHP 错误

1
2
3
4
5
6
7
8
9
10
11
12
use PHPUnit\Framework\TestCase;

class ExpectedErrorTest extends TestCase
{
/**
* @expectedException PHPUnit\Framework\Error\Error
*/
public function testFailingInclude()
{
include 'not_existing_file.php';
}
}

测试输出

直接添加期望输出,然后执行相关函数。和测试异常类似,需要先添加期望,再执行代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use PHPUnit\Framework\TestCase;

class OutputTest extends TestCase
{
public function testExpectFooActualFoo()
{
$this->expectOutputString('foo');
print 'foo';
}

public function testExpectBarActualBaz()
{
$this->expectOutputString('bar');
print 'baz';
}
}

命令行测试

此章节主要说明了命令行的一些格式和可用参数。可以参考官方文档
获取细节。The Command-Line Test Runner

基境

基境就是在测试前需要准备的一系列东西。

比如有的测试需要依赖数据库的数据,那么在测试类运作前需要进行数据的准备。

主要有两个函数 setUptearDown

那为什么不直接用构造函数和析构函数呢?是因为这两个有他用,当然你可可以直接用构造函数,然后
再执行 parent::__construct,但不是麻烦嘛;

组织你的测试代码

可以通过命令行的 --bootstrap 参数来指定启动文件,用于文件加载。正常情况下,可以指向 composer 的 autoload 文件。
也可以在配置文件中配置(推荐)。

1
2
3
4
5
6
7
8
$ phpunit --bootstrap src/autoload.php tests
PHPUnit |version|.0 by Sebastian Bergmann and contributors.

.................................

Time: 636 ms, Memory: 3.50Mb

OK (33 tests, 52 assertions)
1
2
3
4
5
6
7
<phpunit bootstrap="src/autoload.php">
<testsuites>
<testsuite name="money">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>

有风险的测试(Risky Tests)

无用测试(Useless Tests)

默认情况下,如果你的测试函数没有添加预期或者断言,就会被认为是无用测试。

通过设置 --dont-report-useless-tests 命令行参数,或者在 xml 配置
文件中配置 beStrictAboutTestsThatDoNotTestAnything="false" 来更改
这一默认行为。

意外的代码覆盖(Unintentionally Covered Code)

当打开这个配置后,如果使用 @covers 注释来包含一些文件的覆盖报告,就会被
判定为有风险的测试。

通过设置 --strict-coverage 命令行参数,或者在 xml 配置
文件中配置 beStrictAboutCoversAnnotation="true" 来更改
这一默认行为。

测试过程中有输出(Output During Test Execution)

如果在测试过程中输出文本,则会被认定为有风险的测试。

通过设置 --disallow-test-output 命令行参数,或者在 xml 配置
文件中配置 beStrictAboutOutputDuringTests="true" 来更改
这一默认行为。

测试超时(Test Execution Timeout)

通过注释来限制某些测试不能超过一定时间:

  • @large 60秒
  • @medium 10秒
  • @small 1秒

通过设置 --enforce-time-limit 命令行参数,或者在 xml 配置
文件中配置 enforceTimeLimit="true" 来更改
这一默认行为。

操作全局状态(Global State Manipulation)

phpunit 可以对全局状态进行检测。

通过设置 --strict-global-state 命令行参数,或者在 xml 配置
文件中配置 beStrictAboutChangesToGlobalState="true" 来更改
这一默认行为。

待完善的测试和跳过的测试

处于一些原因,我们希望跳过或者对某些测试方法标记未待完善

待完善的测试

使用 $this->markTestIncomplete 标记待完善的测试

1
2
3
4
5
6
7
8
9
10
11
12
use PHPUnit\Framework\TestCase;

class SampleTest extends TestCase
{
public function testSomething()
{
$this->assertTrue(true, 'This should already work.');

$this->markTestIncomplete(
'This test has not been implemented yet.'
);
}

跳过的测试

使用 markTestSkipped 来标记跳过的测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use PHPUnit\Framework\TestCase;

class DatabaseTest extends TestCase
{
protected function setUp()
{
if (!extension_loaded('mysqli')) {
$this->markTestSkipped(
'The MySQLi extension is not available.'
);
}
}

public function testConnection()
{
// ...
}
}

结合 @require 来跳过测试

以下的示例中,使用 require 来限定测试需要依赖 mysqli 拓展和 5.3 以上
的 PHP 版本,否则跳过测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use PHPUnit\Framework\TestCase;

/**
* @requires extension mysqli
*/
class DatabaseTest extends TestCase
{
/**
* @requires PHP 5.3
*/
public function testConnection()
{
// Test requires the mysqli extension and PHP >= 5.3
}

// ... All other tests require the mysqli extension
}

数据库测试

使用数据库测试之前先安装拓展 composer require --dev phpunit/dbunit

总的来说,我们在好多的测试场景中都会用到数据库,我们可以结合 PHPUnit 的基境
章节中提到的 setUp 来进行测试。

我们来看一个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
use PHPUnit\DbUnit\DataSet\ArrayDataSet;
use PHPUnit\DbUnit\TestCaseTrait;
use PHPUnit\Framework\TestCase;

/**
* 测试之前,需要在 MySQL 中新建数据库 phpunit,并且新建表 guestbook
* CREATE TABLE `guestbook` (
* `id` bigint(20) NOT NULL,
* `content` varchar(255) COLLATE utf8mb4_bin NOT NULL,
* `user` varchar(255) COLLATE utf8mb4_bin NOT NULL,
* `created` datetime NOT NULL
* ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
* Class ConnectionTest.
*
* @requires extension pdo_mysql
*/
class ConnectionTest extends TestCase
{
use TestCaseTrait;

/**
* @return \PHPUnit\DbUnit\Database\DefaultConnection
*/
public function getConnection()
{
$pdo = new \PDO(
'mysql:host=127.0.0.1:33060;dbname=phpunit;charset=utf8mb4',
'root',
'112233'
);

return $this->createDefaultDBConnection($pdo);
}

/**
* 请注意,phpunit每次会先 TRUNCATE 数据库,然后把下面数组的数据插入进去.
*
* @return ArrayDataSet
*/
public function getDataSet()
{
return new ArrayDataSet(
[
'guestbook' => [
[
'id' => 1,
'content' => 'Hello buddy!',
'user' => 'joe',
'created' => '2010-04-24 17:15:23',
],
[
'id' => 2,
'content' => 'I like it!',
'user' => 'mike',
'created' => '2010-04-26 12:14:20',
],
],
]
);
}

public function testCreateDataSet()
{
$this->markTestSkipped('just an example, skipped');
$tableNames = ['guestbook'];
$dataSet = $this->getConnection()->createDataSet();
}

public function testCreateQueryTable()
{
$this->markTestSkipped('just an example, skipped');
$tableNames = ['guestbook'];
$queryTable = $this->getConnection()->createQueryTable('guestbook', 'SELECT * FROM guestbook');
}

public function testGetRowCount()
{
$this->assertSame(2, $this->getConnection()->getRowCount('guestbook'));
}

/**
* 测试表的内容和给定的数据集相等.
*/
public function testAddEntry()
{
$queryTable = $this->getConnection()->createQueryTable(
'guestbook', 'SELECT * FROM guestbook'
);
$expectedTable = $this->createFlatXmlDataSet(__DIR__.'/expectedBook.xml')
->getTable('guestbook');
$this->assertTablesEqual($expectedTable, $queryTable);
}
}

其中 getConnectiongetDataSet 都是 TestCaseTrait 中提供的方法,
我们在 getConnection 中设定数据库的链接动作,同时在 getDataSet 中设定
需要往数据库中写入的数据,注意,每次执行这个测试类时,都会执行

  1. 清空数据库 TRUNCATE
  2. 填充数据

如何验证呢?加一个字段为 datetime 类型,设置位数据库自动更新时间,即可看到每次执行测试时,时间都在变化

测试桩

所谓的桩测试,其实就是对一些类的方法进行一番 mock,强制其返回一些数据。因为在开发中,有一些类
依赖于第三方服务,而第三方服务又属于“不可控”因素,所以这个时候就需要使用“桩”了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
use PHPUnit\Framework\TestCase;

class StubTest extends TestCase
{
public function testStub()
{
// Create a stub for the SomeClass class.
$stub = $this->createMock(SomeClass::class);

// Configure the stub.
$stub->method('doSomething')
->willReturn('foo');

// Calling $stub->doSomething() will now return
// 'foo'.
$this->assertSame('foo', $stub->doSomething());
}

public function testStub2()
{
// Create a stub for the SomeClass class.
$stub = $this->getMockBuilder(SomeClass::class)
->disableOriginalConstructor()
->disableOriginalClone()
->disableArgumentCloning()
->disallowMockingUnknownTypes()
->getMock();

// Configure the stub.
$stub->method('doSomething')
->willReturn('foo');

// Calling $stub->doSomething() will now return
// 'foo'.
$this->assertSame('foo', $stub->doSomething());
}

public function testReturnArgumentStub()
{
// Create a stub for the SomeClass class.
$stub = $this->createMock(SomeClass::class);

// Configure the stub.
$stub->method('doSomething')
->will($this->returnArgument(0));

// $stub->doSomething('foo') returns 'foo'
$this->assertSame('foo', $stub->doSomething('foo'));

// $stub->doSomething('bar') returns 'bar'
$this->assertSame('bar', $stub->doSomething('bar'));
}

public function testReturnSelf()
{
// Create a stub for the SomeClass class.
$stub = $this->createMock(SomeClass::class);

// Configure the stub.
$stub->method('doSomething')
->will($this->returnSelf());

// $stub->doSomething() returns $stub
$this->assertSame($stub, $stub->doSomething());
}

public function testReturnValueMapStub()
{
// Create a stub for the SomeClass class.
$stub = $this->createMock(SomeClass::class);

// Create a map of arguments to return values.
$map = [
['a', 'b', 'c', 'd'],
['e', 'f', 'g', 'h'],
];

// Configure the stub.
$stub->method('doSomething')
->will($this->returnValueMap($map));

// $stub->doSomething() returns different values depending on
// the provided arguments.
$this->assertSame('d', $stub->doSomething('a', 'b', 'c'));
$this->assertSame('h', $stub->doSomething('e', 'f', 'g'));
}

public function testReturnCallbackStub()
{
// Create a stub for the SomeClass class.
$stub = $this->createMock(SomeClass::class);

// Configure the stub.
$stub->method('doSomething')
->will($this->returnCallback('str_rot13'));

// $stub->doSomething($argument) returns str_rot13($argument)
$this->assertSame('fbzrguvat', $stub->doSomething('something'));
}

/**
* 按照指定顺序返回列表中的值
*/
public function testOnConsecutiveCallsStub()
{
// 为 SomeClass 类创建桩件。
$stub = $this->createMock(SomeClass::class);

// 配置桩件。
$stub->method('doSomething')
->will($this->onConsecutiveCalls(2, 3, 5, 7));

// $stub->doSomething() 每次返回值都不同
$this->assertSame(2, $stub->doSomething());
$this->assertSame(3, $stub->doSomething());
$this->assertSame(5, $stub->doSomething());
}

public function testThrowExceptionStub()
{
$this->expectException(\Exception::class);

// 为 SomeClass 类创建桩件
$stub = $this->createMock(SomeClass::class);

// 配置桩件。
$stub->method('doSomething')
->will($this->throwException(new \Exception()));

// $stub->doSomething() 抛出异常
$stub->doSomething();
}
}

class SomeClass
{
public function doSomething()
{
// Do something.
}
}

我们看到, SomeClassdoSomething() 本身没有返回数据,我们通过桩动作,来完成了测试。

这个在测试依赖于第三方服务的相关代码时很管用。

代码覆盖度

白名单文件(Whitelisting Files)

添加到白名单文件的文件或者文件夹,会进行代码覆盖度统计的工作。具体的参数可以看帮助文档

### 忽略代码块(Ignoring Code Blocks)

我们可以添加部分代码不进行覆盖度统计。通过一些注释来标记即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use PHPUnit\Framework\TestCase;

/**
* @codeCoverageIgnore
*/
class Foo
{
public function bar()
{
}
}

class Bar
{
/**
* @codeCoverageIgnore
*/
public function foo()
{
}
}

if (false) {
// @codeCoverageIgnoreStart
print '*';
// @codeCoverageIgnoreEnd
}

exit; // @codeCoverageIgnore

执行方法进行统计(Specifying Covered Methods)

同样通过添加注释标记的方法来执行需要覆盖的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
use PHPUnit\Framework\TestCase;

class BankAccountTest extends TestCase
{
protected $ba;

protected function setUp()
{
$this->ba = new BankAccount;
}

/**
* @covers BankAccount::getBalance
*/
public function testBalanceIsInitiallyZero()
{
$this->assertSame(0, $this->ba->getBalance());
}

/**
* @covers BankAccount::withdrawMoney
*/
public function testBalanceCannotBecomeNegative()
{
try {
$this->ba->withdrawMoney(1);
}

catch (BankAccountException $e) {
$this->assertSame(0, $this->ba->getBalance());

return;
}

$this->fail();
}

/**
* @covers BankAccount::depositMoney
*/
public function testBalanceCannotBecomeNegative2()
{
try {
$this->ba->depositMoney(-1);
}

catch (BankAccountException $e) {
$this->assertSame(0, $this->ba->getBalance());

return;
}

$this->fail();
}

/**
* @covers BankAccount::getBalance
* @covers BankAccount::depositMoney
* @covers BankAccount::withdrawMoney
*/
public function testDepositWithdrawMoney()
{
$this->assertSame(0, $this->ba->getBalance());
$this->ba->depositMoney(1);
$this->assertSame(1, $this->ba->getBalance());
$this->ba->withdrawMoney(1);
$this->assertSame(0, $this->ba->getBalance());
}
}