使用PHPUnit做单元测试

PHPUnit是最流行的PHP测试框架。配置是使用非常简单。此外,这个框架支持代码覆盖率报告,还有需要额外的插件。上一小节中的Codeception使用PHPUnit来工作,并写单元测试。在这个小节中,我们将会使用PHPUnit测试创建一个购物车扩展示例。

准备

按照官方指南http://www.yiiframework.com/doc-2.0/guide-start-installation.html的描述,使用Composer包管理器创建一个新的yii2-app-basic应用。

如何做…

首先,我们必须为我们的扩展创建一个新的空目录。

准备扩展结构

  1. 首先,为你的扩展创建目录结构:
  1. book
  2. └── cart
  3. ├── src
  4. └── tests

为了将这个扩展做为一个Composer包,准备book/cart/composer.json

  1. {
  2. "name": "book/cart",
  3. "type": "yii2-extension",
  4. "require": {
  5. "yiisoft/yii2": "~2.0"
  6. },
  7. "require-dev": {
  8. "phpunit/phpunit": "4.*"
  9. },
  10. "autoload": {
  11. "psr-4": {
  12. "book\\cart\\": "src/",
  13. "book\\cart\\tests\\": "tests/"
  14. }
  15. },
  16. "extra": {
  17. "asset-installer-paths": {
  18. "npm-asset-library": "vendor/npm",
  19. "bower-asset-library": "vendor/bower"
  20. }
  21. }
  22. }
  1. book/cart/.gitignore文件中添加如下内容:
  1. /vendor
  2. /composer.lock
  1. 添加如下内容到PHPUnit默认配置文件中book/cart/phpunit.xml.dist
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <phpunit bootstrap="./tests/bootstrap.php"
  3. colors="true"
  4. convertErrorsToExceptions="true"
  5. convertNoticesToExceptions="true"
  6. convertWarningsToExceptions="true"
  7. stopOnFailure="false">
  8. <testsuites>
  9. <testsuite name="Test Suite">
  10. <directory>./tests</directory>
  11. </testsuite>
  12. </testsuites>
  13. <filter>
  14. <whitelist>
  15. <directory suffix=".php">./src/</directory>
  16. </whitelist>
  17. </filter>
  18. </phpunit>
  1. 安装扩展的所有依赖:
  1. composer install
  1. 现在我们可以获取如下结构:
  1. book
  2. └── cart
  3. ├── src
  4. ├── tests
  5. ├── .gitignore
  6. ├── composer.json
  7. ├── phpunit.xml.dist
  8. └── vendor

写扩展代码

为了写扩展代码,执行如下步骤:

  1. src文件夹中,创建book\cart\Cart类:
  1. <?php
  2. namespace book\cart;
  3. use book\cart\storage\StorageInterface;
  4. use yii\base\Component;
  5. use yii\base\InvalidConfigException;
  6. class Cart extends Component
  7. {
  8. /**
  9. * @var StorageInterface
  10. */
  11. private $_storage;
  12. /**
  13. * @var array
  14. */
  15. private $_items;
  16. public function setStorage($storage)
  17. {
  18. if (is_array($storage)) {
  19. $this->_storage = \Yii::createObject($storage);
  20. } else {
  21. $this->_storage = $storage;
  22. }
  23. }
  24. public function add($id, $amount = 1)
  25. {
  26. $this->loadItems();
  27. if (isset($this->_items[$id])) {
  28. $this->_items[$id] += $amount;
  29. } else {
  30. $this->_items[$id] = $amount;
  31. }
  32. $this->saveItems();
  33. }
  34. public function set($id, $amount)
  35. {
  36. $this->loadItems();
  37. $this->_items[$id] = $amount;
  38. $this->saveItems();
  39. }
  40. public function remove($id)
  41. {
  42. $this->loadItems();
  43. if (isset($this->_items[$id])) {
  44. unset($this->_items[$id]);
  45. }
  46. $this->saveItems();
  47. }
  48. public function clear()
  49. {
  50. $this->loadItems();
  51. $this->_items = [];
  52. $this->saveItems();
  53. }
  54. public function getItems()
  55. {
  56. $this->loadItems();
  57. return $this->_items;
  58. }
  59. public function getCount()
  60. {
  61. $this->loadItems();
  62. return count($this->_items);
  63. }
  64. public function getAmount()
  65. {
  66. $this->loadItems();
  67. return array_sum($this->_items);
  68. }
  69. private function loadItems()
  70. {
  71. if ($this->_storage === null) {
  72. throw new InvalidConfigException('Storage must be set');
  73. }
  74. if ($this->_items === null) {
  75. $this->_items = $this->_storage->load();
  76. }
  77. }
  78. private function saveItems()
  79. {
  80. $this->_storage->save($this->_items);
  81. }
  82. }
  1. src/storage子文件夹中创建StorageInterface接口:
  1. <?php
  2. namespace book\cart\storage;
  3. interface StorageInterface
  4. {
  5. /**
  6. * @return array
  7. */
  8. public function load();
  9. /**
  10. * @param array $items
  11. */
  12. public function save(array $items);
  13. }

以及SessionStorage类:

  1. <?php
  2. namespace book\cart\storage;
  3. use Yii;
  4. class SessionStorage implements StorageInterface
  5. {
  6. public $sessionKey = 'cart';
  7. public function load()
  8. {
  9. return Yii::$app->session->get($this->sessionKey, []);
  10. }
  11. public function save(array $items)
  12. {
  13. Yii::$app->session->set($this->sessionKey, $items);
  14. }
  15. }}
  1. 现在我们可以得到如下结构:
  1. book
  2. └── cart
  3. ├── src
  4. ├── storage
  5. ├── SessionStorage.php
  6. └── StorageInterface.php
  7. └── Cart.php
  8. ├── tests
  9. ├── .gitignore
  10. ├── composer.json
  11. ├── phpunit.xml.dist
  12. └── vendor

写扩展测试

为了实施这个扩展测试,执行如下步骤:

  1. 为PHPUnit添加book/cart/tests/bootstrap.php入口:
  1. <?php
  2. defined('YII_DEBUG') or define('YII_DEBUG', true);
  3. defined('YII_ENV') or define('YII_ENV', 'test');
  4. require(__DIR__ . '/../vendor/autoload.php');
  5. require(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');
  1. 在每一个测试前,通过初始化Yii应用创建一个测试基类,然后在销毁应用时释放:
  1. <?php
  2. namespace book\cart\tests;
  3. use yii\di\Container;
  4. use yii\web\Application;
  5. abstract class TestCase extends \PHPUnit_Framework_TestCase
  6. {
  7. protected function setUp()
  8. {
  9. parent::setUp();
  10. $this->mockApplication();
  11. }
  12. protected function tearDown()
  13. {
  14. $this->destroyApplication();
  15. parent::tearDown();
  16. }
  17. protected function mockApplication()
  18. {
  19. new Application([
  20. 'id' => 'testapp',
  21. 'basePath' => __DIR__,
  22. 'vendorPath' => dirname(__DIR__) . '/vendor',
  23. ]);
  24. }
  25. protected function destroyApplication()
  26. {
  27. \Yii::$app = null;
  28. \Yii::$container = new Container();
  29. }
  30. }
  1. 添加一个基于内存的干净的fake类,这个类继承StorageInterface接口:
  1. <?php
  2. namespace book\cart\tests\storage;
  3. use book\cart\storage\StorageInterface;
  4. class FakeStorage implements StorageInterface
  5. {
  6. private $items = [];
  7. public function load()
  8. {
  9. return $this->items;
  10. }
  11. public function save(array $items)
  12. {
  13. $this->items = $items;
  14. }
  15. }

它将会存储一些条目到一个私有变量中,而不是一个真正的session中。它允许我们能独立运行这个测试(不需要真正的存储驱动),并提高测试性能。

  1. 添加CartTest类:
  1. <?php
  2. namespace book\cart\tests;
  3. use book\cart\Cart;
  4. use book\cart\tests\storage\FakeStorage;
  5. class CartTest extends TestCase
  6. {
  7. /**
  8. * @var Cart
  9. */
  10. private $cart;
  11. public function setUp()
  12. {
  13. parent::setUp();
  14. $this->cart = new Cart(['storage' => new
  15. FakeStorage()]);
  16. }
  17. public function testEmpty()
  18. {
  19. $this->assertEquals([], $this->cart->getItems());
  20. $this->assertEquals(0, $this->cart->getCount());
  21. $this->assertEquals(0, $this->cart->getAmount());
  22. }
  23. public function testAdd()
  24. {
  25. $this->cart->add(5, 3);
  26. $this->assertEquals([5 => 3], $this->cart->getItems());
  27. $this->cart->add(7, 14);
  28. $this->assertEquals([5 => 3, 7 => 14],
  29. $this->cart->getItems());
  30. $this->cart->add(5, 10);
  31. $this->assertEquals([5 => 13, 7 => 14],
  32. $this->cart->getItems());
  33. }
  34. public function testSet()
  35. {
  36. $this->cart->add(5, 3);
  37. $this->cart->add(7, 14);
  38. $this->cart->set(5, 12);
  39. $this->assertEquals([5 => 12, 7 => 14],
  40. $this->cart->getItems());
  41. }
  42. public function testRemove()
  43. {
  44. $this->cart->add(5, 3);
  45. $this->cart->remove(5);
  46. $this->assertEquals([], $this->cart->getItems());
  47. }
  48. public function testClear()
  49. {
  50. $this->cart->add(5, 3);
  51. $this->cart->add(7, 14);
  52. $this->cart->clear();
  53. $this->assertEquals([], $this->cart->getItems());
  54. }
  55. public function testCount()
  56. {
  57. $this->cart->add(5, 3);
  58. $this->assertEquals(1, $this->cart->getCount());
  59. $this->cart->add(7, 14);
  60. $this->assertEquals(2, $this->cart->getCount());
  61. }
  62. public function testAmount()
  63. {
  64. $this->cart->add(5, 3);
  65. $this->assertEquals(3, $this->cart->getAmount());
  66. $this->cart->add(7, 14);
  67. $this->assertEquals(17, $this->cart->getAmount());
  68. }
  69. public function testEmptyStorage()
  70. {
  71. $cart = new Cart();
  72. $this->setExpectedException('yii\base\InvalidConfigException');
  73. $cart->getItems();
  74. }
  75. }
  1. 添加一个独立的测试,用于检查SessionStorage类:
  1. <?php
  2. namespace book\cart\tests\storage;
  3. use book\cart\storage\SessionStorage;
  4. use book\cart\tests\TestCase;
  5. class SessionStorageTest extends TestCase
  6. {
  7. /**
  8. * @var SessionStorage
  9. */
  10. private $storage;
  11. public function setUp()
  12. {
  13. parent::setUp();
  14. $this->storage = new SessionStorage(['key' => 'test']);
  15. }
  16. public function testEmpty()
  17. {
  18. $this->assertEquals([], $this->storage->load());
  19. }
  20. public function testStore()
  21. {
  22. $this->storage->save($items = [1 => 5, 6 => 12]);
  23. $this->assertEquals($items, $this->storage->load());
  24. }
  25. }
  1. 现在我们可以得到如下结构:
  1. book
  2. └── cart
  3. ├── src
  4. ├── storage
  5. ├── SessionStorage.php
  6. └── StorageInterface.php
  7. └── Cart.php
  8. ├── tests
  9. ├── storage
  10. ├── FakeStorage.php
  11. └── SessionStorageTest.php
  12. ├── bootstrap.php
  13. ├── CartTest.php
  14. └── TestCase.php
  15. ├── .gitignore
  16. ├── composer.json
  17. ├── phpunit.xml.dist
  18. └── vendor

运行测试

在使用composer install命令安装所有的依赖时,Composer包管理器安装了PHPUnit包到vendor文件中,并将可执行的文件phpunit放在了vendor/bin子文件夹中。

现在我们可以运行如下脚本:

  1. cd book/cart
  2. vendor/bin/phpunit

我们可以看到如下测试报告:

  1. PHPUnit 4.8.26 by Sebastian Bergmann and contributors.
  2. ..........
  3. Time: 906 ms, Memory: 11.50MB
  4. OK (10 tests, 16 assertions)

每一个点都对应了一次成功的测试。

  1. class Cart extends Component
  2. {
  3. public function remove($id)
  4. {
  5. $this->loadItems();
  6. if (isset($this->_items[$id])) {
  7. // unset($this->_items[$id]);
  8. }
  9. $this->saveItems();
  10. }
  11. ...
  12. }
  1. 'components' => [
  2. // …
  3. 'cart' => [
  4. 'class' => 'book\cart\Cart',
  5. 'storage' => [
  6. 'class' => 'book\cart\storage\SessionStorage',
  7. ],
  8. ],
  9. ],

再次运行测试:

  1. PHPUnit 4.8.26 by Sebastian Bergmann and contributors.
  2. ...F......
  3. Time: 862 ms, Memory: 11.75MB
  4. There was 1 failure:
  5. 1) book\cart\tests\CartTest::testRemove
  6. Failed asserting that two arrays are equal.
  7. --- Expected
  8. +++ Actual
  9. @@ @@
  10. Array (
  11. + 5 => 3
  12. )
  13. /book/cart/tests/CartTest.php:52
  14. FAILURES!
  15. Tests: 10, Assertions: 16, Failures: 1

在这个例子中,我们看到了一次失败(用F标记),以及一次错误报告。

分析代码覆盖率

你必须安装XDebug PHP扩展,https://xdebug.org。例如,在Ubuntu或者Debian上,你可以在终端中,输入如下命令:

  1. sudo apt-get install php5-xdebug

在Windows上,你必须打开php.ini文件,并添加自定义代码到PHP安装路径中:

  1. [xdebug]
  2. zend_extension_ts=C:/php/ext/php_xdebug.dll

或者,如果你使用非线程安全的版本,输入如下内容:

  1. [xdebug]
  2. zend_extension=C:/php/ext/php_xdebug.dll

安装过XDebug以后,使用--coverage-html标志再次运行测试,并指定一个报告路径:

  1. vendor/bin/phpunit --coverage-html tests/_output

在浏览器中打开tests/_output/index.html,你将会看到每一个路径和类的一个明确的覆盖率报告:

使用PHPUnit做单元测试 - 图1

你可以点击任何类,并分析代码的哪一行在测试期间还没有被执行。例如,打开Cart类报告:

使用PHPUnit做单元测试 - 图2

在我们的例子中,我们忘记测试从数组配置中创建storage。

组件的使用

在Packagist上发布扩展后,我们可以安装一个one-to-any项目:

  1. composer require book/cart

此外,在应用的配置文件中激活组件:

  1. 'components' => [
  2. // …
  3. 'cart' => [
  4. 'class' => 'book\cart\Cart',
  5. 'storage' => [
  6. 'class' => 'book\cart\storage\SessionStorage',
  7. ],
  8. ],
  9. ],

另外一种方法,不需要在Packagist上发布扩展,我们必须设置@book alias,从而激活正确的类自动加载:

  1. $config = [
  2. 'id' => 'basic',
  3. 'basePath' => dirname(__DIR__),
  4. 'bootstrap' => ['log'],
  5. 'aliases' => [
  6. '@book' => dirname(__DIR__) . '/book',
  7. ],
  8. 'components' => [
  9. 'cart' => [
  10. 'class' => 'book\cart\Cart',
  11. 'storage' => [
  12. 'class' => 'book\cart\storage\SessionStorage',
  13. ],
  14. ],
  15. // ...
  16. ],
  17. ]

无论如何,我们可以在我们的项目中以Yii::$app->cart组件的方式使用它:

工作原理…

在创建我们自己的测试前,你必须创建一个子目录,并在你的项目的根目录中添加phpinit.xml或者phpunit.xml.dist文件:

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <phpunit bootstrap="./tests/bootstrap.php"
  3. colors="true"
  4. convertErrorsToExceptions="true"
  5. convertNoticesToExceptions="true"
  6. convertWarningsToExceptions="true"
  7. stopOnFailure="false">
  8. <testsuites>
  9. <testsuite name="Test Suite">
  10. <directory>./tests</directory>
  11. </testsuite>
  12. </testsuites>
  13. <filter>
  14. <whitelist>
  15. <directory suffix=".php">./src/</directory>
  16. </whitelist>
  17. </filter>
  18. </phpunit>

如果第二个文件在工作目录中不存在,PHPUnit从第二个文件中加载配置。此外,你可以通过创建bootstrap.php文件来初始化autoloader和你框架的环境:

  1. <?php
  2. defined('YII_DEBUG') or define('YII_DEBUG', true);
  3. defined('YII_ENV') or define('YII_ENV', 'test');
  4. require(__DIR__ . '/../vendor/autoload.php');
  5. require(__DIR__ . '/../vendor/yiisoft/yii2/Yii.php');

最后,你可以通过Composer安装PHPUnit(局部或者全局),并在有XML配置文件的目录中使用phpunit控制台命令。

PHPUnit扫描测试文件,并找到一*Test.php为后缀的文件。所有的测试类必须继承PHPUnit_Framework_TestCase类,并包含以test*为前缀的公共方法:

  1. class MyTest extends TestCase
  2. {
  3. public function testSomeFunction()
  4. {
  5. $this->assertTrue(true);
  6. }
  7. }

在你的测试中,你可以用任何已有的assert*方法:

  1. $this->assertEqual('Alex', $model->name);
  2. $this->assertTrue($model->validate());
  3. $this->assertFalse($model->save());
  4. $this->assertCount(3, $items);
  5. $this->assertArrayHasKey('username', $model->getErrors());
  6. $this->assertNotNull($model->author);
  7. $this->assertInstanceOf('app\models\User', $model->author);

此外,你可以复写setUp()或者tearDown()方法,用来添加表达式,它将在每一个测试方法之前或者之后运行。

例如,你可以通过重新初始化Yii应用定义自己的基类TestCase

  1. <?php
  2. namespace book\cart\tests;
  3. use yii\di\Container;
  4. use yii\web\Application;
  5. abstract class TestCase extends \PHPUnit_Framework_TestCase
  6. {
  7. protected function setUp()
  8. {
  9. parent::setUp();
  10. $this->mockApplication();
  11. }
  12. protected function tearDown()
  13. {
  14. $this->destroyApplication();
  15. parent::tearDown();
  16. }
  17. protected function mockApplication()
  18. {
  19. new Application([
  20. 'id' => 'testapp',
  21. 'basePath' => __DIR__,
  22. 'vendorPath' => dirname(__DIR__) . '/vendor',
  23. ]);
  24. }
  25. protected function destroyApplication()
  26. {
  27. \Yii::$app = null;
  28. \Yii::$container = new Container();
  29. }
  30. }

现在你可以在你的子类中扩展这个类。你的test方法在会在一个自己的应用实例中运行。它会帮助你避免副作用,并创建独立了测试。

注意

Yii 2.0.*使用旧版的PHPUnit 4.*,用于兼容PHP5.4。

参考