第十一章 测试

在本章中,我们将会讨论如下话题:

  • 使用Codeception测试应用
  • 使用PHPUnit做单元测试
  • 使用Atoum做单元测试
  • 使用Behat做单元测试

介绍

在本章中,你将会学习如何如何使用最好技术用于测试,例如Codeception,PHPUnit,Atoum和Behat。你将会看到如何写简单的测试和如何在你的应用中避免拟合错误。

使用Codeception测试应用

默认情况下,基础和高级Yii2应用skeletons使用Codeception作为一个测试框架。Codeception支持写单元,函数,以及接受box之外的测试。对于单元测试,它使用PHPUnit测试框架,它将被在下个小节中讨论。

准备

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

注意:如果你用的是基础应用的2.0.9版本(或者更早),只需要手动升级tests文件夹,并添加config/test.phpconfig/test_db.phpweb/index-test.php文件。此外你需要复制composer.json文件的requirerequire-dev部分,并运行composer update

  1. 复制并应用如下migration:
  1. <?php
  2. use yii\db\Migration;
  3. class m160309_070856_create_post extends Migration
  4. {
  5. public function up()
  6. {
  7. $this->createTable('{{%post}}', [
  8. 'id' => $this->primaryKey(),
  9. 'title' => $this->string()->notNull(),
  10. 'text' => $this->text()->notNull(),
  11. 'status' => $this->smallInteger()->notNull()->defaultValue(0),
  12. ]);
  13. }
  14. public function down()
  15. {
  16. $this->dropTable('{{%post}}');
  17. }
  18. }
  1. 创建Post模型:
  1. <?php
  2. namespace app\models;
  3. use Yii;
  4. use yii\db\ActiveRecord;
  5. /**
  6. * @property integer $id
  7. * @property string $title
  8. * @property string $text
  9. * @property integer $status
  10. * @property integer $created_at
  11. * @property integer $updated_at
  12. */
  13. class Post extends ActiveRecord
  14. {
  15. const STATUS_DRAFT = 0;
  16. const STATUS_ACTIVE = 1;
  17. public static function tableName()
  18. {
  19. return '{{%post}}';
  20. }
  21. public function rules()
  22. {
  23. return [
  24. [['title', 'text'], 'required'],
  25. [['text'], 'string'],
  26. ['status', 'in', 'range' => [self::STATUS_DRAFT,
  27. self::STATUS_ACTIVE]],
  28. ['status', 'default', 'value' =>
  29. self::STATUS_DRAFT],
  30. [['title'], 'string', 'max' => 255],
  31. ];
  32. }
  33. public function behaviors()
  34. {
  35. return [
  36. TimestampBehavior::className(),
  37. ];
  38. }
  39. public static function getStatusList()
  40. {
  41. return [
  42. self::STATUS_DRAFT => 'Draft',
  43. self::STATUS_ACTIVE => 'Active',
  44. ];
  45. }
  46. public function publish()
  47. {
  48. if ($this->status == self::STATUS_ACTIVE) {
  49. throw new \DomainException('Post is already published.');
  50. }
  51. $this->status = self::STATUS_ACTIVE;
  52. }
  53. public function draft()
  54. {
  55. if ($this->status == self::STATUS_DRAFT) {
  56. throw new \DomainException('Post is already drafted.');
  57. }
  58. $this->status = self::STATUS_DRAFT;
  59. }
  60. }
  1. 生成CRUD:

第十一章 测试 - 图1

  1. 此外,在views/admin/posts/_form.php文件中为status字段添加状态下拉菜单,以及提交按钮的名称:
  1. <div class="post-form">
  2. <?php $form = ActiveForm::begin(); ?>
  3. <?= $form->field($model, 'title')->textInput(['maxlength'
  4. => true]) ?>
  5. <?= $form->field($model, 'text')->textarea(['rows' => 6]) ?>
  6. <?= $form->field($model, 'status')->dropDownList(Post::getStatusList()) ?>
  7. <div class="form-group">
  8. <?= Html::submitButton($model->isNewRecord ? 'Create' :
  9. 'Update', [
  10. 'class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary',
  11. 'name' => 'submit-button',
  12. ]) ?>
  13. </div>
  14. <?php ActiveForm::end(); ?>
  15. </div>
  1. 现在检查控制器是否工作:

第十一章 测试 - 图2

创建任意示例帖子。

如何做…

为测试做准备

跟着如下步骤来为测试做准备:

  1. 创建yii2_basic_tests或者其它测试数据库,通过应用migration来更新它:
  1. tests/bin/yii migrate

这个命令需要在测试文件夹中运行。你可以在配置文件/config/test_db.php中指定你的测试数据库选项。

  1. Codeception为我们的测试套件使用自动生成的Actor类。使用下面的命令创建他们:
  1. composer exec codecept build

运行单元和功能测试

我们可以立即运行任何类型的应用测试:

  1. # run all available tests
  2. composer exec codecept run
  3. # run functional tests
  4. composer exec codecept run functional
  5. # run unit tests
  6. composer exec codecept run unit

结果是,你可以查看测试报告如下所示:

第十一章 测试 - 图3

获取覆盖报告

你可以为你的代码获取代码覆盖率报告。默认情况下,代码覆盖率在配置文件tests/codeception.yml中是禁用的;你需要取消必要的注释,从而可以收集代码覆盖率:

  1. coverage:
  2. enabled: true
  3. whitelist:
  4. include:
  5. - models/*
  6. - controllers/*
  7. - commands/*
  8. - mail/*
  9. blacklist:
  10. include:
  11. - assets/*
  12. - config/*
  13. - runtime/*
  14. - vendor/*
  15. - views/*
  16. - web/*
  17. - tests/*

你需要安装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

最后,你可以运行测试,并使用如下命令收集覆盖率报告:

  1. #collect coverage for all tests
  2. composer exec codecept run --coverage-html
  3. #collect coverage only for unit tests
  4. composer exec codecept run unit --coverage-html
  5. #collect coverage for unit and functional tests
  6. composer exec codecept run functional,unit --coverage-html

你可以在终端上看到文本的代码覆盖率输出:

  1. Code Coverage Report:
  2. 2016-03-31 08:13:05
  3. Summary:
  4. Classes: 20.00% (1/5)
  5. Methods: 40.91% (9/22)
  6. Lines: 30.65% (38/124)
  7. \app\models::ContactForm
  8. Methods: 33.33% ( 1/ 3) Lines: 80.00% ( 12/ 15)
  9. \app\models::LoginForm
  10. Methods: 100.00% ( 4/ 4) Lines: 100.00% ( 18/ 18)
  11. \app\models::User
  12. Methods: 57.14% ( 4/ 7) Lines: 53.33% ( 8/ 15)
  13. Remote CodeCoverage reports are not printed to console
  14. HTML report generated in coverage

此外,你可以在tests/codeception/_output/coverage路径中看到HTML格式的报告:

第十一章 测试 - 图4

你可以点击任何类,并分析代码的哪些行在测试运行过程中还没有被执行到。

运行验收测试

在验收测试中,你可以使用基于Curl的PhpBrowser作为请求服务器。它可以帮助你检查网站控制器,并解析HTTP和HTML响应代码。但是如果你希望测试你的CSS或者JavaScript行为,你必须使用真正的浏览器。

Selenium服务器是一个交互式的工具,它集成到了Firefox以及其它浏览器中,可以让你打开站点页面,并模拟人类动作。

为了使用真正的浏览器,我们必须安装Selenium服务器:

  1. 需要完整版的Codeception包,而不是基础版的:
  1. composer require --dev codeception/codeception
  2. composer remove --dev codeception/base
  1. 下载如下软件:
  1. 在新的控制台窗口中启动带有driver的服务器:
  1. java -jar -Dwebdriver.gecko.driver=~/geckodriver ~/selenium-server-standalone-x.xx.x.jar
  1. 复制tests/acceptance.suite.yml.exampletests/acceptance.suite.yml,并按如下配置:
  1. class_name: AcceptanceTester
  2. modules:
  3. enabled:
  4. - WebDriver:
  5. url: http://127.0.0.1:8080/
  6. browser: firefox
  7. - Yii2:
  8. part: orm
  9. entryScript: index-test.php
  10. cleanup: false
  1. 打开新的终端,并启动web服务器:
  1. tests/bin/yii serve
  1. 运行验收测试:
  1. composer exec codecept run acceptance

你应该能看到Selenium是如何启动浏览器的,并检查所有的站点页面。

创建数据库fixtures

在运行自己的测试之前,我们必须清楚自己的测试数据库,并加载指定的测试数据。yii2-codeception扩展提供ActiveFixture基类,用于为自己的模型创建测试数据集。运行如下步骤创建数据库fixtures:

  1. Post模型创建fixture类:
  1. <?php
  2. namespace tests\fixtures;
  3. use yii\test\ActiveFixture;
  4. class PostFixture extends ActiveFixture
  5. {
  6. public $modelClass = 'app\modules\Post';
  7. public $dataFile = '@tests/_data/post.php';
  8. }
  1. test/_data/post.php文件中添加一个演示数据集:
  1. <?php
  2. return [
  3. [
  4. 'id' => 1,
  5. 'title' => 'First Post',
  6. 'text' => 'First Post Text',
  7. 'status' => 1,
  8. 'created_at' => 1457211600,
  9. 'updated_at' => 1457211600,
  10. ],
  11. [
  12. 'id' => 2,
  13. 'title' => 'Old Title For Updating',
  14. 'text' => 'Old Text For Updating',
  15. 'status' => 1,
  16. 'created_at' => 1457211600,
  17. 'updated_at' => 1457211600,
  18. ],
  19. [
  20. 'id' => 3,
  21. 'title' => 'Title For Deleting',
  22. 'text' => 'Text For Deleting',
  23. 'status' => 1,
  24. 'created_at' => 1457211600,
  25. 'updated_at' => 1457211600,
  26. ],
  27. ];
  1. 为单元测试和验收测试激活fixtures支持。只需要添加fixtures部分到unit.suite.yml文件中:
  1. class_name: UnitTester
  2. modules:
  3. enabled:
  4. - Asserts
  5. - Yii2:
  6. part: [orm, fixtures, email]

此外,添加fixtures部分到acceptance.suite.yml文件中:

  1. class_name: AcceptanceTester
  2. modules:
  3. enabled:
  4. - WebDriver:
  5. url: http://127.0.0.1:8080/
  6. browser: firefox
  7. - Yii2:
  8. part: [orm, fixtures]
  9. entryScript: index-test.php
  10. cleanup: false
  1. 运行如下命令,重新生成tester类,应用这些修改:
  1. composer exec codecept build

写单元或综合测试

单元和综合测试检查我们项目的源代码。

单元测试只检查当前的类,或者他们的方法和其它类无关,以及和其它资源也无关,比如数据库、文件等等。

综合测试检查你的类和其它类以及资源综合在一起时的表现。

Yii2中的ActiveRecord模型总是使用数据库加载表schema,所以我们必须创建一个真正的测试数据库,并且我们的测试将会是综合的。

  1. 写测试用于检查模型的校验、保存,并修改它的状态:
  1. <?php
  2. namespace tests\unit\models;
  3. use app\models\Post;
  4. use Codeception\Test\Unit;
  5. use tests\fixtures\PostFixture;
  6. class PostTest extends Unit
  7. {
  8. /**
  9. * @var \UnitTester
  10. */
  11. protected $tester;
  12. public function _before()
  13. {
  14. $this->tester->haveFixtures([
  15. 'post' => [
  16. 'class' => PostFixture::className(),
  17. 'dataFile' => codecept_data_dir() . 'post.php'
  18. ]
  19. ]);
  20. }
  21. public function testValidateEmpty()
  22. {
  23. $model = new Post();
  24. expect('model should not validate',
  25. $model->validate())->false();
  26. expect('title has error',
  27. $model->errors)->hasKey('title');
  28. expect('title has error',
  29. $model->errors)->hasKey('text');
  30. }
  31. public function testValidateCorrect()
  32. {
  33. $model = new Post([
  34. 'title' => 'Other Post',
  35. 'text' => 'Other Post Text',
  36. ]);
  37. expect('model should validate',
  38. $model->validate())->true();
  39. }
  40. public function testSave()
  41. {
  42. $model = new Post([
  43. 'title' => 'Test Post',
  44. 'text' => 'Test Post Text',
  45. ]);
  46. expect('model should save', $model->save())->true();
  47. expect('title is correct', $model->title)->equals('Test Post');
  48. expect('text is correct', $model->text)->equals('Test Post Text');
  49. expect('status is draft',
  50. $model->status)->equals(Post::STATUS_DRAFT);
  51. expect('created_at is generated',
  52. $model->created_at)->notEmpty();
  53. expect('updated_at is generated',
  54. $model->updated_at)->notEmpty();
  55. }
  56. public function testPublish()
  57. {
  58. $model = new Post(['status' => Post::STATUS_DRAFT]);
  59. expect('post is drafted',
  60. $model->status)->equals(Post::STATUS_DRAFT);
  61. $model->publish();
  62. expect('post is published',
  63. $model->status)->equals(Post::STATUS_ACTIVE);
  64. }
  65. public function testAlreadyPublished()
  66. {
  67. $model = new Post(['status' => Post::STATUS_ACTIVE]);
  68. $this->setExpectedException('\LogicException');
  69. $model->publish();
  70. }
  71. public function testDraft()
  72. {
  73. $model = new Post(['status' => Post::STATUS_ACTIVE]);
  74. expect('post is published',
  75. $model->status)->equals(Post::STATUS_ACTIVE);
  76. $model->draft();
  77. expect('post is drafted',
  78. $model->status)->equals(Post::STATUS_DRAFT);
  79. }
  80. public function testAlreadyDrafted()
  81. {
  82. $model = new Post(['status' => Post::STATUS_ACTIVE]);
  83. $this->setExpectedException('\LogicException');
  84. $model->publish();
  85. }
  86. }
  1. 运行测试:
  1. composer exec codecept run unit
  1. 现在查看结果:

第十一章 测试 - 图5

完成了。如果你故意或者偶然破坏了任何模型的方法,你将会看到一个坏的测试。

写功能测试

功能测试检查你的应用是否正常工作。这个套件会准备$_GET$_POST以及其它请求变量,并调用Application::handleRequest方法。它帮助你测试控制器以及响应,而且这不需要运行真实服务器。

现在我们可以为我们的admin CRUD写测试:

  1. 生成一个新的测试类:
  1. codecept generate:cest functional admin/Posts
  1. 在生成的文件中修正命名空间,并写自己的测试:
  1. <?php
  2. namespace tests\functional\admin;
  3. use app\models\Post;
  4. use FunctionalTester;
  5. use tests\fixtures\PostFixture;
  6. use yii\helpers\Url;
  7. class PostsCest
  8. {
  9. function _before(FunctionalTester $I)
  10. {
  11. $I->haveFixtures([
  12. 'user' => [
  13. 'class' => PostFixture::className(),
  14. 'dataFile' => codecept_data_dir() . 'post.php'
  15. ]
  16. ]);
  17. }
  18. public function testIndex(FunctionalTester $I)
  19. {
  20. $I->amOnPage(['admin/posts/index']);
  21. $I->see('Posts', 'h1');
  22. }
  23. public function testView(FunctionalTester $I)
  24. {
  25. $I->amOnPage(['admin/posts/view', 'id' => 1]);
  26. $I->see('First Post', 'h1');
  27. }
  28. public function testCreateInvalid(FunctionalTester $I)
  29. {
  30. $I->amOnPage(['admin/posts/create']);
  31. $I->see('Create', 'h1');
  32. $I->submitForm('#post-form', [
  33. 'Post[title]' => '',
  34. 'Post[text]' => '',
  35. ]);
  36. $I->expectTo('see validation errors');
  37. $I->see('Title cannot be blank.', '.help-block');
  38. $I->see('Text cannot be blank.', '.help-block');
  39. }
  40. public function testCreateValid(FunctionalTester $I)
  41. {
  42. $I->amOnPage(['admin/posts/create']);
  43. $I->see('Create', 'h1');
  44. $I->submitForm('#post-form', [
  45. 'Post[title]' => 'Post Create Title',
  46. 'Post[text]' => 'Post Create Text',
  47. 'Post[status]' => 'Active',
  48. ]);
  49. $I->expectTo('see view page');
  50. $I->see('Post Create Title', 'h1');
  51. }
  52. public function testUpdate(FunctionalTester $I)
  53. {
  54. // ...
  55. }
  56. public function testDelete(FunctionalTester $I)
  57. {
  58. $I->amOnPage(['/admin/posts/view', 'id' => 3]);
  59. $I->see('Title For Deleting', 'h1');
  60. $I->amGoingTo('delete item');
  61. $I->sendAjaxPostRequest(Url::to(['/admin/posts/delete',
  62. 'id' => 3]));
  63. $I->expectTo('see that post is deleted');
  64. $I->dontSeeRecord(Post::className(), [
  65. 'title' => 'Title For Deleting',
  66. ]);
  67. }
  68. }
  1. 运行测试命令:
  1. composer exec codecept run functional
  1. 现在可以查看到结果:

第十一章 测试 - 图6

所有的测试是通过的。在其它例子中,你可以在tests/_output文件夹中看到失败测试的测试页面的截图:

写验收测试

  1. 验收测试员点击测试服务器中真正的网站,而不是只调用Application::handleRequest方法。高级验收测试看起来像中级功能测试,但是在Selenium中它可以检查在真实浏览器中的Javascript行为。
  2. 你必须在tests/acceptance文件夹下获取如下类:
  1. <?php
  2. namespace tests\acceptance\admin;
  3. use AcceptanceTester;
  4. use tests\fixtures\PostFixture;
  5. use yii\helpers\Url;
  6. class PostsCest
  7. {
  8. function _before(AcceptanceTester $I)
  9. {
  10. $I->haveFixtures([
  11. 'post' => [
  12. 'class' => PostFixture::className(),
  13. 'dataFile' => codecept_data_dir() . 'post.php'
  14. ]
  15. ]);
  16. }
  17. public function testIndex(AcceptanceTester $I)
  18. {
  19. $I->wantTo('ensure that post index page works');
  20. $I->amOnPage(Url::to(['/admin/posts/index']));
  21. $I->see('Posts', 'h1');
  22. }
  23. public function testView(AcceptanceTester $I)
  24. {
  25. $I->wantTo('ensure that post view page works');
  26. $I->amOnPage(Url::to(['/admin/posts/view', 'id' => 1]));
  27. $I->see('First Post', 'h1');
  28. }
  29. public function testCreate(AcceptanceTester $I)
  30. {
  31. $I->wantTo('ensure that post create page works');
  32. $I->amOnPage(Url::to(['/admin/posts/create']));
  33. $I->see('Create', 'h1');
  34. $I->fillField('#post-title', 'Post Create Title');
  35. $I->fillField('#post-text', 'Post Create Text');
  36. $I->selectOption('#post-status', 'Active');
  37. $I->click('submit-button');
  38. $I->wait(3);
  39. $I->expectTo('see view page');
  40. $I->see('Post Create Title', 'h1');
  41. }
  42. public function testDelete(AcceptanceTester $I)
  43. {
  44. $I->amOnPage(Url::to(['/admin/posts/view', 'id' => 3]));
  45. $I->see('Title For Deleting', 'h1');
  46. $I->click('Delete');
  47. $I->acceptPopup();
  48. $I->wait(3);
  49. $I->see('Posts', 'h1');
  50. }
  51. }

不要忘记调用wait方法,用于等待页面被打开或者重新加载。

  1. 在一个新的终端中运行PHP测试服务器:
  1. tests/bin/yii serve
  1. 运行验收测试:
  1. composer exec codecept run acceptance
  1. 查看结果:

第十一章 测试 - 图7

Selenium将会启动Firefox web浏览器,并执行我们的测试命令。

创建API测试套件

除了单元、功能以及验收套件,Codeception可以创建特殊的测试套件。例如,我们可以创建用于支持XML和JSON解析的API测试。

  1. Post模型创建REST API控制器controllers/api/PostsController.php
  1. <?php
  2. namespace app\controllers\api;
  3. use yii\rest\ActiveController;
  4. class PostsController extends ActiveController
  5. {
  6. public $modelClass = '\app\models\Post';
  7. }
  1. config/web.php中为urlManager组件添加REST路由:
  1. 'components' => [
  2. // ...
  3. 'urlManager' => [
  4. 'enablePrettyUrl' => true,
  5. 'showScriptName' => false,
  6. 'rules' => [
  7. ['class' => 'yii\rest\UrlRule', 'controller' => 'api/posts'],
  8. ],
  9. ],
  10. ],

并在config/test.php文件中设置一些配置(启用showScriptName选项):

  1. 'components' => [
  2. // ...
  3. 'urlManager' => [
  4. 'enablePrettyUrl' => true,
  5. 'showScriptName' => true,
  6. 'rules' => [
  7. ['class' => 'yii\rest\UrlRule', 'controller' => 'api/posts'],
  8. ],
  9. ],
  10. ],
  1. web/.htaccess文件添加如下内容:
  1. RewriteEngine On
  2. RewriteCond %{REQUEST_FILENAME} !-f
  3. RewriteCond %{REQUEST_FILENAME} !-d
  4. RewriteRule . index.php
  1. 检查api/posts控制器是否工作:

第十一章 测试 - 图8

  1. 使用REST模块创建API测试套件tests/api.suite.yml配置文件:
  1. class_name: ApiTester
  2. modules:
  3. enabled:
  4. - REST:
  5. depends: PhpBrowser
  6. url: 'http://127.0.0.1:8080/index-test.php'
  7. part: [json]
  8. - Yii2:
  9. part: [orm, fixtures]
  10. entryScript: index-test.php

现在重新编译testers:

  1. composer exec codecept build
  1. 创建tests/api目录,并生成新的测试类:
  1. composer exec codecept generate:cest api Posts
  1. 为你的REST-API写测试:
  1. <?php
  2. namespace tests\api;
  3. use ApiTester;
  4. use tests\fixtures\PostFixture;
  5. use yii\helpers\Url;
  6. class PostsCest
  7. {
  8. function _before(ApiTester $I)
  9. {
  10. $I->haveFixtures([
  11. 'post' => [
  12. 'class' => PostFixture::className(),
  13. 'dataFile' => codecept_data_dir() . 'post.php'
  14. ]
  15. ]);
  16. }
  17. public function testGetAll(ApiTester $I)
  18. {
  19. $I->sendGET('/api/posts');
  20. $I->seeResponseCodeIs(200);
  21. $I->seeResponseIsJson();
  22. $I->seeResponseContainsJson([0 => ['title' => 'First Post']]);
  23. }
  24. public function testGetOne(ApiTester $I)
  25. {
  26. $I->sendGET('/api/posts/1');
  27. $I->seeResponseCodeIs(200);
  28. $I->seeResponseIsJson();
  29. $I->seeResponseContainsJson(['title' => 'First Post']);
  30. }
  31. public function testGetNotFound(ApiTester $I)
  32. {
  33. $I->sendGET('/api/posts/100');
  34. $I->seeResponseCodeIs(404);
  35. $I->seeResponseIsJson();
  36. $I->seeResponseContainsJson(['name' => 'Not Found']);
  37. }
  38. public function testCreate(ApiTester $I)
  39. {
  40. $I->sendPOST('/api/posts', [
  41. 'title' => 'Test Title',
  42. 'text' => 'Test Text',
  43. ]);
  44. $I->seeResponseCodeIs(201);
  45. $I->seeResponseIsJson();
  46. $I->seeResponseContainsJson(['title' => 'Test Title']);
  47. }
  48. public function testUpdate(ApiTester $I)
  49. {
  50. $I->sendPUT('/api/posts/2', [
  51. 'title' => 'New Title',
  52. ]);
  53. $I->seeResponseCodeIs(200);
  54. $I->seeResponseIsJson();
  55. $I->seeResponseContainsJson([
  56. 'title' => 'New Title',
  57. 'text' => 'Old Text For Updating',
  58. ]);
  59. }
  60. public function testDelete(ApiTester $I)
  61. {
  62. $I->sendDELETE('/api/posts/3');
  63. $I->seeResponseCodeIs(204);
  64. }
  65. }
  1. 运行应用服务器:
  1. tests/bin yii serve
  1. 运行API测试:
  1. composer exec codecept run api

现在可以看到结果:

第十一章 测试 - 图9

所有的测试通过了,我们的API能正常工作。

工作原理…

Codeception是一个高级测试框架,基于PHPUnit包可以提供用于写单元、综合、功能和验收测试。

我们可以使用Yii2的内置Codeception,它允许我们加载fixtures,使用models,以及Yii框架的其它东西。

参考

使用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,你将会看到每一个路径和类的一个明确的覆盖率报告:

第十一章 测试 - 图10

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

第十一章 测试 - 图11

在我们的例子中,我们忘记测试从数组配置中创建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。

参考

使用Atoum测试

除了PHPUnit和Codeception,Atoum是一个简单的单元测试框架。你可以使用这个框架,用于测试你的扩展,或者测试你应用的代码。

准备

为新的项目创建一个空文件夹。

如何做…

在这个小节中,我们将会创建一个演示,使用Atoum测试购物车扩展。

准备扩展框架

  1. 首先,为你的扩展创建目录结构:
  1. book
  2. └── cart
  3. ├── src
  4. └── tests
  1. 作为一个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. "atoum/atoum": "^2.7"
  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. 安装扩展所有的依赖:
  1. composer install
  1. 现在我们将会得到如下结构:
  1. book
  2. └── cart
  3. ├── src
  4. ├── tests
  5. ├── .gitignore
  6. ├── composer.json
  7. ├── phpunit.xml.dist
  8. └── vendor

写扩展代码

使用PHPUnit作单元测试小节复制CartStorageInterfaceSessionStorage类。

最后,我们可以得到如下结构:

  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. └── vendor

写扩展测试

  1. 添加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\console\Application;
  5. use mageekguy\atoum\test;
  6. abstract class TestCase extends test
  7. {
  8. public function beforeTestMethod($method)
  9. {
  10. parent::beforeTestMethod($method);
  11. $this->mockApplication();
  12. }
  13. public function afterTestMethod($method)
  14. {
  15. $this->destroyApplication();
  16. parent::afterTestMethod($method);
  17. }
  18. protected function mockApplication()
  19. {
  20. new Application([
  21. 'id' => 'testapp',
  22. 'basePath' => __DIR__,
  23. 'vendorPath' => dirname(__DIR__) . '/vendor',
  24. 'components' => [
  25. 'session' => [
  26. 'class' => 'yii\web\Session',
  27. ],
  28. ]
  29. ]);
  30. }
  31. protected function destroyApplication()
  32. {
  33. \Yii::$app = null;
  34. \Yii::$container = new Container();
  35. }
  36. }
  1. 添加一个基于内存的干净的fake类,并继承StorageInterface接口:
  1. <?php
  2. namespace book\cart\tests;
  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. 添加Cart测试类:
  1. <?php
  2. namespace book\cart\tests\units;
  3. use book\cart\tests\FakeStorage;
  4. use book\cart\Cart as TestedCart;
  5. use book\cart\tests\TestCase;
  6. class Cart extends TestCase
  7. {
  8. /**
  9. * @var TestedCart
  10. */
  11. private $cart;
  12. public function beforeTestMethod($method)
  13. {
  14. parent::beforeTestMethod($method);
  15. $this->cart = new TestedCart(['storage' => new
  16. FakeStorage()]);
  17. }
  18. public function testEmpty()
  19. {
  20. $this->array($this->cart->getItems())->isEqualTo([]);
  21. $this->integer($this->cart->getCount())->isEqualTo(0);
  22. $this->integer($this->cart->getAmount())->isEqualTo(0);
  23. }
  24. public function testAdd()
  25. {
  26. $this->cart->add(5, 3);
  27. $this->array($this->cart->getItems())->isEqualTo([5 =>
  28. 3]);
  29. $this->cart->add(7, 14);
  30. $this->array($this->cart->getItems())->isEqualTo([5 =>
  31. 3, 7 => 14]);
  32. $this->cart->add(5, 10);
  33. $this->array($this->cart->getItems())->isEqualTo([5 =>
  34. 13, 7 => 14]);
  35. }
  36. public function testSet()
  37. {
  38. $this->cart->add(5, 3);
  39. $this->cart->add(7, 14);
  40. $this->cart->set(5, 12);
  41. $this->array($this->cart->getItems())->isEqualTo([5 =>
  42. 12, 7 => 14]);
  43. }
  44. public function testRemove()
  45. {
  46. $this->cart->add(5, 3);
  47. $this->cart->remove(5);
  48. $this->array($this->cart->getItems())->isEqualTo([]);
  49. }
  50. public function testClear()
  51. {
  52. $this->cart->add(5, 3);
  53. $this->cart->add(7, 14);
  54. $this->cart->clear();
  55. $this->array($this->cart->getItems())->isEqualTo([]);
  56. }
  57. public function testCount()
  58. {
  59. $this->cart->add(5, 3);
  60. $this->integer($this->cart->getCount())->isEqualTo(1);
  61. $this->cart->add(7, 14);
  62. $this->integer($this->cart->getCount())->isEqualTo(2);
  63. }
  64. public function testAmount()
  65. {
  66. $this->cart->add(5, 3);
  67. $this->integer($this->cart->getAmount())->isEqualTo(3);
  68. $this->cart->add(7, 14);
  69. $this->integer($this->cart->getAmount())->isEqualTo(17);
  70. }
  71. public function testEmptyStorage()
  72. {
  73. $cart = new TestedCart();
  74. $this->exception(function () use ($cart) {
  75. $cart->getItems();
  76. })->hasMessage('Storage must be set');
  77. }
  78. }
  1. 添加一个独立的测试,用于检查SessionStorage类:
  1. <?php
  2. namespace book\cart\tests\units\storage;
  3. use book\cart\storage\SessionStorage as TestedStorage;
  4. use book\cart\tests\TestCase;
  5. class SessionStorage extends TestCase
  6. {
  7. /**
  8. * @var TestedStorage
  9. */
  10. private $storage;
  11. public function beforeTestMethod($method)
  12. {
  13. parent::beforeTestMethod($method);
  14. $this->storage = new TestedStorage(['key' => 'test']);
  15. }
  16. public function testEmpty()
  17. {
  18. $this
  19. ->given($storage = $this->storage)
  20. ->then
  21. ->array($storage->load())
  22. ->isEqualTo([]);
  23. }
  24. public function testStore()
  25. {
  26. $this
  27. ->given($storage = $this->storage)
  28. ->and($storage->save($items = [1 => 5, 6 => 12]))
  29. ->then
  30. ->array($this->storage->load())
  31. ->isEqualTo($items)
  32. ;
  33. }
  34. }
  1. 现在我们将会得到如下结构:
  1. book
  2. └── cart
  3. ├── src
  4. ├── storage
  5. ├── SessionStorage.php
  6. └── StorageInterface.php
  7. └── Cart.php
  8. ├── tests
  9. ├── units
  10. ├── storage
  11. └── SessionStorage.php
  12. └── Cart.php
  13. ├── bootstrap.php
  14. ├── FakeStorage.php
  15. └── TestCase.php
  16. ├── .gitignore
  17. ├── composer.json
  18. └── vendor

运行测试

在使用composer install命令安装所有的依赖期间,Composer包管理器安装Atoum包到vendor目录中,并将可执行文件atoum放在vendor/bin子文件夹中。

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

  1. cd book/cart
  2. vendor/bin/atoum -d tests/units -bf tests/bootstrap.php

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

  1. > atoum path: /book/cart/vendor/atoum/atoum/vendor/bin/atoum
  2. > atoum version: 2.7.0
  3. > atoum path: /book/cart/vendor/atoum/atoum/vendor/bin/atoum
  4. > atoum version: 2.7.0
  5. > PHP path: /usr/bin/php5
  6. > PHP version:
  7. => PHP 5.5.9-1ubuntu4.16 (cli)
  8. > book\cart\tests\units\Cart...
  9. [SSSSSSSS__________________________________________________][8/8]
  10. => Test duration: 1.13 seconds.
  11. => Memory usage: 3.75 Mb.
  12. > book\cart\tests\units\storage\SessionStorage...
  13. [SS________________________________________________________][2/2]
  14. => Test duration: 0.03 second.
  15. => Memory usage: 1.00 Mb.
  16. > Total tests duration: 1.15 seconds.
  17. > Total tests memory usage: 4.75 Mb.
  18. > Code coverage value: 16.16%

每一个S符号表示一次成功的测试。

尝试通过注释unset操作故意破坏cart:

  1. class Cart extends Component
  2. {
  3. ...
  4. public function remove($id)
  5. {
  6. $this->loadItems();
  7. if (isset($this->_items[$id])) {
  8. // unset($this->_items[$id]);
  9. }
  10. $this->saveItems();
  11. }
  12. ...
  13. }

再次运行测试:

  1. > atoum version: 2.7.0
  2. > PHP path: /usr/bin/php5
  3. > PHP version:
  4. => PHP 5.5.9-1ubuntu4.16 (cli)
  5. book\cart\tests\units\Cart...
  6. [SSFSSSSS__________________________________________________][8/8]
  7. => Test duration: 1.09 seconds.
  8. => Memory usage: 3.25 Mb.
  9. > book\cart\tests\units\storage\SessionStorage...
  10. [SS________________________________________________________][2/2]
  11. => Test duration: 0.02 second.
  12. => Memory usage: 1.00 Mb.
  13. ...
  14. Failure (2 tests, 10/10 methods, 0 void method, 0 skipped method, 0
  15. uncompleted method, 1 failure, 0 error, 0 exception)!
  16. > There is 1 failure:
  17. => book\cart\tests\units\Cart::testRemove():
  18. In file /book/cart/tests/units/Cart.php on line 53, mageekguy\atoum\
  19. asserters\phpArray() failed: array(1) is not equal to array(0)
  20. -Expected
  21. +Actual
  22. @@ -1 +1,3 @@
  23. -array(0) {
  24. +array(1) {
  25. + [5] =>
  26. + int(3)

在这个例子中,我们看到一次错误(用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以后,创建book/cart/coverage.php配置文件,并添加覆盖率报告选项:

  1. <?php
  2. use \mageekguy\atoum;
  3. /** @var atoum\scripts\runner $script */
  4. $report = $script->addDefaultReport();
  5. $coverageField = new atoum\report\fields\runner\coverage\
  6. html('Cart', __DIR__ . '/tests/coverage');
  7. $report->addField($coverageField);

现在使用-c选项来使用这个配置再次运行测试:

  1. vendor/bin/atoum -d tests/units -bf tests/bootstrap.php -c coverage.php

在运行这个测试以后,在浏览器中打开tests/coverage/index.html。你将会看到每一个目录和类的一个明确的覆盖率报告:

第十一章 测试 - 图12

你可以点击任何类,并分析代码的哪些行在测试过程中还没有被执行。

工作原理…

Atoum测试框架支持行为驱动设计(BDD)语法流,如下:

  1. public function testSome()
  2. {
  3. $this
  4. ->given($cart = new TestedCart())
  5. ->and($cart->add(5, 13))
  6. ->then
  7. ->sizeof($cart->getItems())
  8. ->isEqualTo(1)
  9. ->array($cart->getItems())
  10. ->isEqualTo([5 => 3])
  11. ->integer($cart->getCount())
  12. ->isEqualTo(1)
  13. ->integer($cart->getAmount())
  14. ->isEqualTo(3);
  15. }

但是,你可以使用常用的类PHPUnit语法来写单元测试:

  1. public function testSome()
  2. {
  3. $cart = new TestedCart();
  4. $cart->add(5, 3);
  5. $this->array($cart->getItems())->isEqualTo([5 => 3])
  6. ->integer($cart->getCount())->isEqualTo(1)
  7. ->integer($cart->getAmount())->isEqualTo(3);
  8. }

Atoum也支持代码覆盖率报告,用于分析测试质量。

参考

使用Behat作单元测试

Behat是一个BDD框架,以人类可读的语句测试你的代码,这些语句以多个使用例子描述你的代码行为。

准备

为一个新的项目创建一个空的目录。

如何做…

在这个小节中,我们将会创建一个演示,使用Behat测试购物车扩展。

准备扩展结构

  1. 首先,为你的扩展创建一个目录结构:
  1. book
  2. └── cart
  3. ├── src
  4. └── features
  1. 作为一个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. "behat/behat": "^3.1"
  10. },
  11. "autoload": {
  12. "psr-4": {
  13. "book\\cart\\": "src/",
  14. "book\\cart\\features\\": "features/"
  15. }
  16. },
  17. "extra": {
  18. "asset-installer-paths": {
  19. "npm-asset-library": "vendor/npm",
  20. "bower-asset-library": "vendor/bower"
  21. }
  22. }
  23. }
  1. 添加如下内容到book/cart/.gitignore
  1. /vendor
  2. /composer.lock
  1. 安装扩展所有的依赖:
  1. composer install
  1. 现在我们得到如下结构:
  1. book
  2. └── cart
  3. ├── src
  4. ├── features
  5. ├── .gitignore
  6. ├── composer.json
  7. └── vendor

写扩展代码

使用PHPUnit做单元测试小节复制CartStorageInterfaceSessionStorage类。

最后,我们得到如下结构。

  1. book
  2. └── cart
  3. ├── src
  4. ├── storage
  5. ├── SessionStorage.php
  6. └── StorageInterface.php
  7. └── Cart.php
  8. ├── features
  9. ├── .gitignore
  10. ├── composer.json
  11. └── vendor

写扩展测试

  1. 添加book/cart/features/bootstrap/bootstrap.php入口脚本:
  1. <?php
  2. defined('YII_DEBUG') or define('YII_DEBUG', true);
  3. defined('YII_ENV') or define('YII_ENV', 'test');
  4. require_once __DIR__ . '/../../vendor/yiisoft/yii2/Yii.php';
  1. 创建features/cart.feature文件,并写cart测试场景:
  1. Feature: Shopping cart
  2. In order to buy products
  3. As a customer
  4. I need to be able to put interesting products into a cart
  5. Scenario: Checking empty cart
  6. Given there is a clean cart
  7. Then I should have 0 products
  8. Then I should have 0 product
  9. And the overall cart amount should be 0
  10. Scenario: Adding products to the cart
  11. Given there is a clean cart
  12. When I add 3 pieces of 5 product
  13. Then I should have 3 pieces of 5 product
  14. And I should have 1 product
  15. And the overall cart amount should be 3
  16. When I add 14 pieces of 7 product
  17. Then I should have 3 pieces of 5 product
  18. And I should have 14 pieces of 7 product
  19. And I should have 2 products
  20. And the overall cart amount should be 17
  21. When I add 10 pieces of 5 product
  22. Then I should have 13 pieces of 5 product
  23. And I should have 14 pieces of 7 product
  24. And I should have 2 products
  25. And the overall cart amount should be 27
  26. Scenario: Change product count in the cart
  27. Given there is a cart with 5 pieces of 7 product
  28. When I set 3 pieces for 7 product
  29. Then I should have 3 pieces of 7 product
  30. Scenario: Remove products from the cart
  31. Given there is a cart with 5 pieces of 7 product
  32. When I add 14 pieces of 7 product
  33. And I clear cart
  34. Then I should have empty cart
  1. 添加features/storage.feature存储测试文件:
  1. Feature: Shopping cart storage
  2. I need to be able to put items into a storage
  3. Scenario: Checking empty storage
  4. Given there is a clean storage
  5. Then I should have empty storage
  6. Scenario: Save items into storage
  7. Given there is a clean storage
  8. When I save 3 pieces of 7 product to the storage
  9. Then I should have 3 pieces of 7 product in the storage
  1. features/bootstrap/CartContext.php文件中,为所有的步骤添加实现:
  1. <?php
  2. use Behat\Behat\Context\SnippetAcceptingContext;
  3. use book\cart\Cart;
  4. use book\cart\features\bootstrap\storage\FakeStorage;
  5. use yii\di\Container;
  6. use yii\web\Application;
  7. require_once __DIR__ . '/bootstrap.php';
  8. class CartContext implements SnippetAcceptingContext
  9. {
  10. /**
  11. * @var Cart
  12. * */
  13. private $cart;
  14. /**
  15. * @Given there is a clean cart
  16. */
  17. public function thereIsACleanCart()
  18. {
  19. $this->resetCart();
  20. }
  21. /**
  22. * @Given there is a cart with :pieces of :product product
  23. */
  24. public function thereIsAWhichCostsPs($product, $amount)
  25. {
  26. $this->resetCart();
  27. $this->cart->set($product, floatval($amount));
  28. }
  29. /**
  30. * @When I add :pieces of :product
  31. */
  32. public function iAddTheToTheCart($product, $pieces)
  33. {
  34. $this->cart->add($product, $pieces);
  35. }
  36. /**
  37. * @When I set :pieces for :arg2 product
  38. */
  39. public function iSetPiecesForProduct($pieces, $product)
  40. {
  41. $this->cart->set($product, $pieces);
  42. }
  43. /**
  44. * @When I clear cart
  45. */
  46. public function iClearCart()
  47. {
  48. $this->cart->clear();
  49. }
  50. /**
  51. * @Then I should have empty cart
  52. */
  53. public function iShouldHaveEmptyCart()
  54. {
  55. PHPUnit_Framework_Assert::assertEquals(
  56. 0,
  57. $this->cart->getCount()
  58. );
  59. }
  60. /**
  61. * @Then I should have :count product(s)
  62. */
  63. public function iShouldHaveProductInTheCart($count)
  64. {
  65. PHPUnit_Framework_Assert::assertEquals(
  66. intval($count),
  67. $this->cart->getCount()
  68. );
  69. }
  70. /**
  71. * @Then the overall cart amount should be :amount
  72. */
  73. public function theOverallCartPriceShouldBePs($amount)
  74. {
  75. PHPUnit_Framework_Assert::assertSame(
  76. intval($amount),
  77. $this->cart->getAmount()
  78. );
  79. }
  80. /**
  81. * @Then I should have :pieces of :product
  82. */
  83. public function iShouldHavePiecesOfProduct($pieces,
  84. $product)
  85. {
  86. PHPUnit_Framework_Assert::assertArraySubset(
  87. [intval($product) => intval($pieces)],
  88. $this->cart->getItems()
  89. );
  90. }
  91. private function resetCart()
  92. {
  93. $this->cart = new Cart(['storage' => new
  94. FakeStorage()]);
  95. }
  96. }
  1. 此外,在features/bootstrap/StorageContext.php文件中,添加如下内容:
  1. <?php
  2. use Behat\Behat\Context\SnippetAcceptingContext;
  3. use book\cart\Cart;
  4. use book\cart\features\bootstrap\storage\FakeStorage;
  5. use book\cart\storage\SessionStorage;
  6. use yii\di\Container;
  7. use yii\web\Application;
  8. require_once __DIR__ . '/bootstrap.php';
  9. class StorageContext implements SnippetAcceptingContext
  10. {
  11. /**
  12. * @var SessionStorage
  13. * */
  14. private $storage;
  15. /**
  16. * @Given there is a clean storage
  17. */
  18. public function thereIsACleanStorage()
  19. {
  20. $this->mockApplication();
  21. $this->storage = new SessionStorage(['key' => 'test']);
  22. }
  23. /**
  24. * @When I save :pieces of :product to the storage
  25. */
  26. public function iSavePiecesOfProductToTheStorage($pieces,
  27. $product)
  28. {
  29. $this->storage->save([$product => $pieces]);
  30. }
  31. /**
  32. * @Then I should have empty storage
  33. */
  34. public function iShouldHaveEmptyStorage()
  35. {
  36. PHPUnit_Framework_Assert::assertCount(
  37. 0,
  38. $this->storage->load()
  39. );
  40. }
  41. /**
  42. * @Then I should have :pieces of :product in the storage
  43. */
  44. public function
  45. iShouldHavePiecesOfProductInTheStorage($pieces, $product)
  46. {
  47. PHPUnit_Framework_Assert::assertArraySubset(
  48. [intval($product) => intval($pieces)],
  49. $this->storage->load()
  50. );
  51. }
  52. private function mockApplication()
  53. {
  54. Yii::$container = new Container();
  55. new Application([
  56. 'id' => 'testapp',
  57. 'basePath' => __DIR__,
  58. 'vendorPath' => __DIR__ . '/../../vendor',
  59. ]);
  60. }
  61. }
  1. 添加features/bootstrap/CartContext/FakeStorage.php文件,这是一个fake存储类:
  1. <?php
  2. namespace book\cart\features\bootstrap\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. }
  1. 添加book/cart/behat.yml
  1. default:
  2. suites:
  3. default:
  4. contexts:
  5. - CartContext
  6. - StorageContext
  1. 现在我们将得到如下结构:
  1. book
  2. └── cart
  3. ├── src
  4. ├── storage
  5. ├── SessionStorage.php
  6. └── StorageInterface.php
  7. └── Cart.php
  8. ├── features
  9. ├── bootstrap
  10. ├── storage
  11. └── FakeStorage.php
  12. ├── bootstrap.php
  13. ├── CartContext.php
  14. └── StorageContext.php
  15. ├── cart.feature
  16. └── storage.feature
  17. ├── .gitignore
  18. ├── behat.yml
  19. ├── composer.json
  20. └── vendor

现在我们运行我们的测试。

运行测试

在使用composer install命令安装所有依赖期间,Composer包管理器安装Behat包到vendor目录中,并将可执行文件behat放到vendor/bin子文件夹中。

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

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

此外,我们将会看到如下测试报告:

  1. Feature: Shopping cart
  2. In order to buy products
  3. As a customer
  4. I need to be able to put interesting products into a cart
  5. Scenario: Checking empty cart # features/cart.feature:6
  6. Given there is a clean cart # thereIsACleanCart()
  7. Then I should have 0 products #
  8. iShouldHaveProductInTheCart()
  9. Then I should have 0 product #
  10. iShouldHaveProductInTheCart()
  11. And the overall cart amount should be 0 #
  12. theOverallCartPriceShouldBePs()
  13. ...
  14. Feature: Shopping cart storage
  15. I need to be able to put items into a storage
  16. Scenario: Checking empty storage # features/storage.feature:4
  17. Given there is a clean storage # thereIsACleanStorage()
  18. Then I should have empty storage # iShouldHaveEmptyStorage()
  19. ...
  20. 6 scenarios (6 passed)
  21. 31 steps (31 passed)
  22. 0m0.23s (13.76Mb)

通过注释unset操作,故意破坏cart:

  1. class Cart extends Component
  2. {
  3. public function set($id, $amount)
  4. {
  5. $this->loadItems();
  6. // $this->_items[$id] = $amount;
  7. $this->saveItems();
  8. }
  9. ...
  10. }

现在再次运行测试:

  1. Feature: Shopping cart
  2. In order to buy products
  3. As a customer
  4. Feature: Shopping cart
  5. In order to buy products
  6. As a customer
  7. I need to be able to put interesting products into a cart
  8. ...
  9. Scenario: Change product count in the cart # features/
  10. cart.feature:31
  11. Given there is a cart with 5 pieces of 7 prod #
  12. thereIsAWhichCostsPs()
  13. When I set 3 pieces for 7 product #
  14. iSetPiecesForProduct()
  15. Then I should have 3 pieces of 7 product #
  16. iShouldHavePiecesOf()
  17. Failed asserting that an array has the subset Array &0 (
  18. 7 => 3
  19. ).
  20. Scenario: Remove products from the cart # features/
  21. cart.feature:36
  22. Given there is a cart with 5 pieces of 7 prod #
  23. thereIsAWhichCostsPs()
  24. When I add 14 pieces of 7 product #
  25. iAddTheToTheCart()
  26. And I clear cart # iClearCart()
  27. Then I should have empty cart #
  28. iShouldHaveEmptyCart()
  29. --- Failed scenarios:
  30. features/cart.feature:31
  31. 6 scenarios (5 passed, 1 failed)
  32. 31 steps (30 passed, 1 failed)
  33. 0m0.22s (13.85Mb)

在这个例子中,我们看到了一次失败和一次失败报告。

工作原理…

Behat是一个BDD测试框架。它促进writing preceding human-readable testing scenarios to low-level technical implementation。

当我们为每一个特性写场景时,我们可以使用操作的一个集合:

  1. Scenario: Adding products to the cart
  2. Given there is a clean cart
  3. When I add 3 pieces of 5 product
  4. Then I should have 3 pieces of 5 product
  5. And I should have 1 product
  6. And the overall cart amount should be 3

Behat解析我们的句子,并找到相关的实现:

  1. class FeatureContext implements SnippetAcceptingContext
  2. {
  3. /**
  4. * @When I add :pieces of :product
  5. */
  6. public function iAddTheToTheCart($product, $pieces)
  7. {
  8. $this->cart->add($product, $pieces);
  9. }
  10. }

你可以创建一个单FeatureContext类(默认),或者为特性集合场景创建指定的上下文的集合。

参考

欲了解更多关于Behat的信息,参考如下URL:

欲了解更多关于其它测试框架的信息,参考本章中的其它小节。