《Yii 1.1应用程序开发实例》

第五章:测试你的应用程序

本章中,我们将涵括以下内容:

  • Setting up the testing environment
  • Writing and running unit tests
  • Using fixtures
  • Testing the application with functional tests
  • Generating code coverage reports

介绍

In a small application, the value of testing can be unnoticeable. In large applications, it is different. When application grows and you start modifying your code, it becomes difficult not to break anything else relying on it. Even if you hire a team of professional testers, you are slowing the development a lot. However, automated testing can partially solve this problem.

Another application of automated testing is TDD (Test Driven Development). The idea is simple: When you know how a component will work, write down your requirements in a form of automated test prior to implementing a component. This way in the end, you will know if your component works as expected and will not have to write tests later.

Setting up the testing environment

In this recipe, we will prepare a testing environment that can be used to run automated test supports: unit tests and functional tests. Unit tests in Yii are based on PHPUnit and functional tests are based on selenium server. Additionally, you need Xdebug to generate code coverage reports.

准备工作

  • Make sure that you have properly configured PHP to work in a command-line mode.
  • Use the yiic webapp tool to generate a fresh application.

怎么做...

We will start with PHPUnit.

  1. To install it, we need to set up PEAR first. In most Linux environments, it is already set up, so you can skip this part if it already works.To test if PEAR works, open console and type pear. You should get the following output:
  1. If you get the preceding output after running pear, then everything is OK. If not, then you need to install it by carrying out the following steps:
  • Open http://pear.php.net/go-pear and save the content as a PHP file go-pear.php. Then, run it in the console with php go-pear.php and follow instructions.
  • In Windows, it is useful to add the PEAR location to the PATH environment variable, so it can be used simply as pear.
  1. Now, it is time to install the PHPUnit. Open the console and type the following:
pear channel-discover pear.phpunit.de
pear channel-discover components.ez.no
pear channel-discover pear.symfony-project.com
pear install phpunit/PHPUnit
  1. Now, type phpunit and you should get the following output:
  1. Done. Now let's install the Selenium Server. There is no PEAR package for it, so go to http://seleniumhq.org/download/ and download the latest release of the Selenium Server project. You should get an archive named like selenium-server- standalone-2.0rc2.jar.
  1. In order to run the server, you should have the Java runtime environment installed. Go to the server directory and type the following:
java -jar selenium-server-standalone-2.0rc2.jar

You should get something similar to the following:

  1. That is it. The server is up and running. Now, we move onto Xdebug. Go to http://www.xdebug.org/download.php and download the latest binaries or the source for your platform. On Linux, the extension should be built from the source. Under Windows, you just put dll somewhere. Then, in your php.ini you need to add the following:
[xdebug]
zend_extension=c:/path/to/your/php_xdebug_version.dll

If you are running Linux, then you should provide the absolute path to php_xdebug_ version.so compiled according to http://www.xdebug.org/docs/install.In order to test if Xdebug is installed, you can use http://www.xdebug.org/ find-binary.php.

Now, you should have all tools up and running.

  1. Now, we will review the application generated by using yiic webapp. Everything test-related was put under protected/tests:
fixtures
functional
report
unit
bootstrap.php
WebTestCase.php
phpunit.xml

Folders generated are used to store different tests, fixtures, and code coverage reports.

  1. bootstrap.php is used to initialize Yii application environment to run tests in it:
// change the following paths if necessary
$yiit=dirname(__FILE__).'/../../../framework/yiit.php';
$config=dirname(__FILE__).'/../config/test.php';
 
require_once($yiit);
require_once(dirname(__FILE__).'/WebTestCase.php');
 
Yii::createWebApplication($config);

As we can see, it uses a separate configuration file under protected/config/ test.php, so if you use database or cache, you need to configure it in this file.

  1. WebTestCase.php is used as a base class for all functional tests. We need to edit it and set TEST_BASE_URL to the URL of your website. Finally, phpunit.xml is the standard PHPUnit configuration file. We don't need to touch it, unless you want to add more browsers for functional tests or to configure the global PHPUnit settings.

还有更多...

In order to get a more detailed installation guide, you can refer to the following URLs:

  • http://pear.php.net/manual/en/installation.php
  • http://phpunit.de/
  • http://seleniumhq.org/docs/05_selenium_rc.html

另请参阅

  • The recipe named Writing and running unit tests in this chapter
  • The recipe named Using fixtures in this chapter
  • The recipe named Testing the application with functional tests in this chapter
  • The recipe named Generating code coverage reports in this chapter

Writing and running unit tests

Unit testing is generally used to test relatively standalone components of application, such as API wrappers of different services or classes by implementing your own custom logic.

In this recipe, we will review the structure of a unit test, most useful methods of PHPUnit, and a way to run a test from yiic console.

As an example, we will follow the TDD approach to create a class that will generate an HTML markup from a limited amount of BBCode tags. For simplicity, we will support only [b], [i], and [url].

准备工作

Make sure that you have a ready to use application and testing tools as described in Setting up the testing environment recipe in this chapter.

怎么做...

  1. First, let's define the syntax and some use cases:
InOut
[b]test[/b]<strong>test</strong>
[i]test[/i]<em>test</em>
[url]http://yiiframework.com/[/url]<a href="http://yiiframework.com/">http://yiiframework.com/</a>
[url=http://yiiframework.com/]yiiframework.com[/url]<a href="http://yiiframework.com/">yiiframework.com</a>
[b]test1[/b] [b]test2[/b]<strong>test1</strong>
<strong>test2</strong>
[b][i]test[/i][/b]<strong><em>test</em></strong>
  1. We will call our class EBBCode and its method to convert BBCode to HTML EBBCode::process, and write unit tests. Create a test file protected/tests/ unit/BBCodeTest.php as follows:
<?php
class BBCodeTest extends CTestCase
{
    private function process($bbCode)
    {
        $bb = new EBBCode();
        return $bb->process($bbCode);
    }
    
    function testSingleTags()
    {
        $this->assertEquals('<strong>test</strong>',
        $this->process('[b]test[/b]'));
        $this->assertEquals('<em>test</em>',
        $this->process('[i]test[/i]'));
        $this->assertEquals(
            '<a href="http://yiiframework.com/">
                       http://yiiframework.com/</a>',
            $this->process('[url]http://yiiframework.com/[/url]')
        );
        $this->assertEquals(
            '<a href="http://yiiframework.com/">
                yiiframework.com</a>',
            $this->process('[url=http://yiiframework.com/]yiiframework.com[/url]')
        );
    }
    
    function testMultipleTags()
    {
        $this->assertEquals(
            '<strong>test1</strong> <strong>test2</strong>',
            $this->process('[b]test1[/b] [b]test2[/b]')
        );
        $this->assertEquals(
            '<strong><em>test</em></strong>',
            $this->process('[b][i]test[/i][/b]')
        );
    }
}
  1. Here we run the BBCode converter with different input strings and check if the output matches with what we are expecting. Now, we will run a test and make sure it fails. Open the console and type the following:
cd path/to/protected/tests
phpunit unit/BBCodeTest.php

You should get an output similar to the following screenshot:

  1. EE means that there are two tests and two errors each marked with E and a summary at the end doubles it with a readable text. In the middle, there are errors telling us why tests have failed. In our case, both tests failed because we have not created any implementation yet.
  1. Now, we will fix it. As the error states, we need an EBBCode.php file with an EBBCode class inside. Create one in protected/components/EBBCode.php as follows:
<?php
class EBBCode
{
}

Run tests again. Tests are still failing, but this time we have a different error, as seen in the following screenshot:

  1. It tells us to create a method named EBBCode::process. Create it and run tests again. The output will be different again because we did what was instructed by the test:
  1. So, now there are no fatal errors and PHPUnit tells us that for input string provided at line 12 of BBCodeTest.php, we should get output as <strong>test</strong>, but we got an empty string. So, let's do some implementation to fix it:
<?php
class EBBCode
{
    function process($string)
    {
        $preg = array(
            '~\[~\[b\](.*)\[/b\]~i' => '<strong>$1</strong>',
            '~\[i\](.*)\[/i\]~i' => '<em>$1</em>',
            '~\[url\](.*)\[/url\]~i' => '<a href="$1">$1</a>',
            '~\[url=([^]]+)\](.*)\[/url\]~i' => '<a href="$1">$2</a>',
        );
        return preg_replace(array_keys($preg), array_values($preg), $string);
    }
}
  1. Now, the test output should be similar to the one shown in the following screenshot:
  1. .F (in the preceding screenshot) means that one test passed and one failed. As we can see in the error message, nested tags were processed wrong. So, we will fix this by using a non-greedy modifier for regular expressions as follows:
<?php
class EBBCode
{
    function process($string)
    {
        $preg = array(
            '~\[b\](.*?)\[/b\]~i' => '<strong>$1</strong>',
            '~\[i\](.*?)\[/i\]~i' => '<em>$1</em>',
            '~\[url\](.*?)\[/url\]~i' => '<a href="$1">$1</a>',
            '~\[url=([^]]+)\](.*?)\[/url\]~i' => '<a href="$1">$2</a>',
        );
        return preg_replace(array_keys($preg), array_values($preg), $string);
    }
}
  1. Now, test results are the ones we'd like to see:
  1. All tests passed which means that we have the BBCode class implemented in the right way, or at least in the way we have planned.

The preceding process, which allowed us to create a test that fails to implement the functionality that passes it, is called a Test Driven Development or TDD. It allows you to implement exactly what is planned and be sure that it will not break in the future.

它是如何工作的...

The CTestCase class we have used is a wrapper around PHPUnit PHPUnit_Framework_ TestCase. It does not provide any additional functionality, but it is used to initialize the PHPUnit class loader. We have defined two methods with names starting with test. This means that they will be run as test methods. Inside, we are performing the same processing with a different input and comparing the output with the result expected by using the PHPUnit_Framework_TestCase::assertEquals method. This method simply checks if two values are equal. If they are not, an error is generated and the test method is considered as failed.

还有更多...

If you need to check different assertion types, then you can use additional PHPUnit_ Framework_TestCase methods, such as assertTrue, assertFileExists, or assertRegExp.

There are more features in PHPUnit which are described in the official documentation at the following URL:

http://www.phpunit.de/manual/current/en/

另请参阅

  • The recipe named Setting up the testing environment in this chapter

Using fixtures

Unit tests perform their job just fine when you test the class logic. However, when it comes to classes that work with environment and data, it becomes a little complicated. What data should we test against? How to get the same environment each time a test is being executed?

In order to be useful, a unit test should be repeatable. That is why we need to reset the data state on every test run. In PHPUnit, we can do this by using a feature named fixtures. When it comes to database, Yii have a helpful database fixtures addition.

For our example, we will test a coupon system which handles the coupon codes registration. The coupon will be stored in a database table named coupon having two columns: id and description. For simplicity, coupon component simply deletes the already registered coupon record and echoes its description.

准备工作

  • Make sure that you have a ready to use application and testing tools as described in the Setting up the testing environment recipe in this chapter.
  • Create an active record model for coupon protected/models/Coupon.php as follows:
<?php
class Coupon extends CActiveRecord
{
    public static function model($className=__CLASS__)
    {
        return parent::model($className);
    }
    
    public function tableName() {
        return 'coupon';
    }
    
    public function rules() {
        return array(
            array('description', 'required'),
        );
    }
}
  • Create a coupon manager class protected/components/CouponManager.php as follows:
<?php
class CouponManager
{
    function registerCoupon($code)
    {
        $coupon = Coupon::model()->findByPk($code);
        if(!$coupon)
            return false;
        echo "Coupon registered. $coupon->description";
        return $coupon->delete();
    }
}

怎么做...

OK, now we will write tests. Inside protected/tests/unit, create CouponTest.php. We will need two test cases.

  1. The first case will test the existing coupon code handling and the second case will test the non-existing coupon code handling. In addition, we need to configure a database, create tables, and insert data in the tables prior to the execution of the test. In the end, we will have the following code:
<?php
class CouponTest extends CDbTestCase
{
    public $fixtures = array(
        'coupon' => 'Coupon',
    );
    
    public static function setUpBeforeClass()
    {
        if(!extension_loaded('pdo') ||
            !extension_loaded('pdo_sqlite'))
            markTestSkipped('PDO and SQLite extensions are required.');
            
        $config=array(
            'basePath'=>dirname(__FILE__),
            'components'=>array(
                'db'=>array(
                    'class'=>'system.db.CDbConnection',
                    'connectionString'=>'sqlite::memory:',
                ),
                'fixture'=>array(
                    'class'=>'system.test.CDbFixtureManager',
                ),
            ), 
        );
        
        Yii::app()->configure($config);
        
        $c = Yii::app()->getDb()->createCommand();
        $c->createTable('coupon', array(
                'id' => 'varchar(255) PRIMARY KEY NOT NULL',
                'description' => 'text',
            ));
        }
 
        public static function tearDownAfterClass()
        {
            if(Yii::app()->getDb())
                Yii::app()->getDb()->active=false;
        }
        
        protected function setUp()
        {
            parent::setUp();
            $_GET['existing_code'] = 'discount_for_me';
            $_GET['non_existing_code'] = 'non_existing';
        }
        
        public function testCodeAcceptance()
        {
            $cm = new CouponManager();
            $this->assertTrue($cm->registerCoupon($_GET['existing_code']));
            $this->assertFalse((boolean)Coupon::model()->findByPk
                ($_GET['existing_code']));
        }
        
        public function testCodeNotFound()
        {
            $countBefore = Coupon::model()->count();
            $cm = new CouponManager();
            $this->assertFalse($cm->registerCoupon($_GET['non_existing_code']));
            $countAfter = Coupon::model()->count();
            $this->assertEquals($countBefore, $countAfter);
        }
}
  1. Also, we need to define fixtures in protected/tests/fixtures/coupon.php as follows:
<?php
return array(
    array(
        'id' => 'free_book',
        'description' => 'Choose one book for free!',
    ), 
    array(
        'id' => 'merry_christmas',
        'description' => '5% Christmas discount!',
    ),
    array(
        'id' => 'discount_for_me',
        'description' => '5% discount special for you!',
    ),
);

Here, id and description keys corresponding to table or active record model fields and values are to be filled to these fields when the fixture is applied.

  1. Now, try to run the test from the console as follows:
cd path/to/protected/tests
phpunit unit/CouponTest.php

You should now get the following output:

它是如何工作的...

We will review the test execution flow. This time, we used a few special PHPUnit fixture methods named setUpBeforeClass and setUp. The setUpBeforeClass method executes once after the test class is instantiated and typically used to initialize things common for all test methods of this class. In our case, we check if we have PDO and SQLite required to run this test:

if(!extension_loaded('pdo') || !extension_loaded('pdo_sqlite'))
      markTestSkipped('PDO and SQLite extensions are required.');

We then create a configuration test application and apply the following:

$config=array(
    'basePath'=>dirname(__FILE__),
    'components'=>array(
        'db'=>array(
            'class'=>'system.db.CDbConnection',
            'connectionString'=>'sqlite::memory:',
        ),
        'fixture'=>array(
            'class'=>'system.test.CDbFixtureManager',
        ),
    ),
);
 
Yii::app()->configure($config);

We use an in-memory SQLite database to speed tests up and avoid creating and deleting database files. In addition, we connect a fixture component which we use to fill data.

Now, it is time to create the database schema. In our case, it is the coupon table:

$c = Yii::app()->getDb()->createCommand();
$c->createTable('coupon', array(
    'id' => 'varchar(255) PRIMARY KEY NOT NULL',
    'description' => 'text',
));

That is it. The test class is created and PHPUnit starts to execute test methods one by one. Before each test method execution, it calls setUp. In our case, it is simple:

parent::setUp();
$_GET['existing_code'] = 'discount_for_me';
$_GET['non_existing_code'] = 'non_existing';

parent::setUp refers to CDbTestCase::setup that reads data from fixtures and places it into the tables or models. Both fixtures and models are defined in the $fixtures property as follows:

public $fixtures = array(
    'coupon' => 'Coupon',
);

This means that fixtures are read from protected/tests/fixtures/coupon.php and applied to the Coupon model. In order to apply fixtures to the table without using a model, we can define a $fixtures property in the following way:

public $fixtures = array(
    'coupon' => ':coupon',
);

Then, we set the environment with $_GET variables. Similarly, you can set cookies, server variables, class properties, and so on.

After executing testCodeAcceptance and before the testCodeNotFound method execution, setUp is executed again restoring the coupon table data from the fixture.

Finally, when all test methods have been executed, we need to close the connection. We do this in the tearDownAfterClass method executed just before the class is destroyed.

还有更多...

We have used Yii database fixtures and it allowed the data not to be cleaned up manually on each execution. If we need to clean up data, then we can use the PHPUnit teardown method that is executed after each test method execution.

You can refer to the following sources to get more information about fixtures:

  • http://www.phpunit.de/manual/current/en/fixtures.html
  • http://www.yiiframework.com/doc/guide/en/test.fixture

另请参阅

  • The recipe named Setting up the testing environment in this chapter

Testing the application with functional tests

While unit tests are used to test standalone components or component groups, functional tests allow testing the complete application like black box testing. We don't know what is inside and can only provide an input and get/verify the output. In our case, the input is actions the user carries out in a browser, such as clicking on buttons or links, loading pages, and so on, and the output is what happens in the browser.

For our example, we will create a simple "check all" widget with a single button that checks and unchecks all checkboxes on the current page.

准备工作

  • Make sure that you have a ready-to-use application and testing tools as described in the Setting up the testing environment recipe in this chapter
  • Don't forget to run the server
  • Drop the widget code into protected/components/ECheckAllWidget.php as follows:
<?php
class ECheckAllWidget extends CWidget
{
    public $checkedTitle = 'Uncheck all';
   
    public $uncheckedTitle = 'Check all';
    public function run()
    {
        Yii::app()->clientScript->registerCoreScript('jquery');
        echo CHtml::button($this->uncheckedTitle, array(
            'id' => 'button-'.$this->id,
            'class' => 'check-all-btn',
            'onclick' => '
                switch($(this).val())
                {
                    case "'.$this->checkedTitle.'":
                        $(this).val("'.$this->uncheckedTitle.'");
                        $("input[type=checkbox]").attr("checked", false);
                        break;
                    case "'.$this->uncheckedTitle.'":
                        $(this).val("'.$this->checkedTitle.'");
                        $("input[type=checkbox]").attr("checked", true);
                        break;
                }
            '
        ));
    }
}
  • Now create protected/controllers/CheckController.php as follows:
<?php
class CheckController extends Controller
{
    function actionIndex()
    {
        $this->render('index');
    }
}
  • In addition, create a view protected/views/check/index.php as follows:
<?php $this->widget('ECheckAllWidget')?>
 
<?php echo CHtml::checkBox('test1', true)?>
<?php echo CHtml::checkBox('test2', false)?>
<?php echo CHtml::checkBox('test3', true)?>
<?php echo CHtml::checkBox('test4', false)?>
<?php echo CHtml::checkBox('test5', true)?>
<?php echo CHtml::checkBox('test6', true)?>

怎么做...

Let's imagine how we would test it manually:

  • Load the page
  • Verify that the button title is "Check all"
  • Click on a button once
  • Verify that all checkboxes are checked and the button title is "Uncheck all"
  • Click on a button again
  • Verify that all checkboxes are unchecked and the button title is "Check all"

Now, we will implement the functional test doing exactly that.

  1. Create protected/tests/functional/CheckAllWidgetTest.php as follows:
<?php
class CheckAllWidgetTest extends WebTestCase
{
    public function testWidget()
    {
        $this->open('check/index');
        $this->assertEquals("Check all",
            $this->getAttribute("class=check-all-btn@value"));
            
        $this->click("class=check-all-btn");
        $this->assertChecked("css=input[type=checkbox]");
        $this->assertEquals("Uncheck all",
            $this->getAttribute("class=check-all-btn@value"));
            
        $this->click("class=check-all-btn");
        $this->assertNotChecked("css=input[type=checkbox]");
        $this->assertEquals("Check all",
            $this->getAttribute("class=check-all-btn@value"));
    }
}
  1. Now open the console:
cd path/to/protected/tests
phpunit functional/CheckAllWidgetTest.php

You should get the following output:

  1. Now, try to break a widget and run the test again. For example, you can comment the onclick handler:

This means that your widget does not work as expected. In this case, the test failed on line 11.

$this->assertEquals("Uncheck all",
    $this->getAttribute("class=check-all-btn@value"));

Actual value was still "Check all" but test expected "Uncheck all".

它是如何工作的...

PHPUnit starts executing all methods named like testSomething one by one. As we have only one method, it executes testWidget:

$this->open('check/index');

It opens a page we created for testing:

$this->assertEquals("Check all", $this->getAttribute
    ("class=check-all-btn@value"));

It gets button text and checks if it equals the string provided. The getAttribute searches for a DOM element with a class check-all-btn and returns its value attribute text.

$this->click("class=check-all-btn");

It clicks an element with class check-all-btn. That is the button.

$this->assertChecked("css=input[type=checkbox]");

The assertChecked and assertNotChecked methods are used to find whether a checkbox is checked. This time we use a CSS selector input[type=checkbox] to get all checkboxes.

Inside, we call methods such as getAttribute or assertChecked, and pass it to the Selenium Server that does the actual work and returns the result.

还有更多...

In order to learn more about the functional testing, you can refer to the following resources:

  • http://www.yiiframework.com/doc/guide/en/test.functional
  • http://www.phpunit.de/manual/current/en/selenium.html
  • http://seleniumhq.org/docs/05_selenium_rc.html
  • http://seleniumhq.org/docs/04_selenese_commands.html

另请参阅

  • The recipe named Setting up the testing environment in this chapter
  • The recipe named Writing and running unit tests in this chapter

Generating code coverage reports

It is very important to know how well your application is tested. If you wrote all tests by yourself, then you can probably guess it, but if there is a team or you are working on a relatively old project, guessing will not work. Fortunately, there is a way to generate code coverage reports using PHPUnit and Xdebug. This report gives information about how well the application is tested, which lines are being executed while running tests, and which are not.

As an example, we will generate a report for the Yii framework core base classes.

准备工作

The Yii framework core tests are not included into release distributions, so we need to check it out from the SVN repository.

Make sure that your test environment is setup properly.

怎么做...

  1. Go to http://code.google.com/p/yii/source/checkout and follow the given instructions to check out the trunk code using either command line or one of the GUI clients, such as SmartSVN or TortoiseSVN.
  2. In the console, enter the following:
cd path/to/checked/out/code/tests/ 
phpunit --coverage-html reports framework/base
  1. After the report is generated, go to path/to/checked/out/code/tests/ reports and open index.html in your browser.

它是如何工作的...

The code coverage report that was generated can tell us how well the project was tested and which parts required more testing to be done.

The code coverage report is generated based only on tests you have run. It is better to run the complete test pack for the project to get the actual information, but for simplicity and speed, only a few tests of the Yii framework code were executed.

The first report page gives us an overview: which files were tested, how many lines, methods, and classes were executed. As we are interested in covering as much code as we can, things to look for are displayed in red and yellow. In the following screenshot, we can see that more tests for CApplication and CStatePersister need to be written:

If we click on a class name, for example, on CComponent, then we get more details. The following screenshot shows the class level report:

Moreover, the actual code shows what is covered in green:

If we click on the dashboard link on the main report page, then we get a handy summary showing most risky untested classes and methods, as shown in the following screenshot:

还有更多...

In order to learn more about the code coverage, refer to the PHPUnit manual at the following URLs:

  • http://www.phpunit.de/manual/current/en/code-coverage-analysis.html
  • http://www.phpunit.de/manual/current/en/selenium.html

另请参阅

  • The recipe named Setting up the testing environment in this chapter
评论 X

      友荐云推荐
      Copyright 2011-2014. YiiBook.com