Symfony unit tests are simple PHP files ending in Test.php and located in the test/unit/ directory of your application. They follow a simple and readable syntax.
Listing 15-1 shows a typical set of unit tests for the strtolower() function. It starts by an instantiation of the lime_test object (you don't need to worry about the parameters for now). Each unit test is a call to a method of the lime_test instance. The last parameter of these methods is always an optional string that serves as the output.
Listing 15-1 - Example Unit Test File, in test/unit/strtolowerTest.php
<?php include(dirname(__FILE__).'/../bootstrap/unit.php'); require_once(dirname(__FILE__).'/../../lib/strtolower.php'); $t = new lime_test(7, new lime_output_color()); // strtolower() $t->diag('strtolower()'); $t->isa_ok(strtolower('Foo'), 'string', 'strtolower() returns a string'); $t->is(strtolower('FOO'), 'foo', 'strtolower() transforms the input to lowercase'); $t->is(strtolower('foo'), 'foo', 'strtolower() leaves lowercase characters unchanged'); $t->is(strtolower('12#?@~'), '12#?@~', 'strtolower() leaves non alphabetical characters unchanged'); $t->is(strtolower('FOO BAR'), 'foo bar', 'strtolower() leaves blanks alone'); $t->is(strtolower('FoO bAr'), 'foo bar', 'strtolower() deals with mixed case input'); $t->is(strtolower(''), 'foo', 'strtolower() transforms empty strings into foo');
Launch the test set from the command line with the test:unit task. The command-line output is very explicit, and it helps you localize which tests failed and which passed. See the output of the example test in Listing 15-2.
Listing 15-2 - Launching a Single Unit Test from the Command Line
> php symfony test:unit strtolower 1..7 # strtolower() ok 1 - strtolower() returns a string ok 2 - strtolower() transforms the input to lowercase ok 3 - strtolower() leaves lowercase characters unchanged ok 4 - strtolower() leaves non alphabetical characters unchanged ok 5 - strtolower() leaves blanks alone ok 6 - strtolower() deals with mixed case input not ok 7 - strtolower() transforms empty strings into foo # Failed test (.\batch\test.php at line 21) # got: '' # expected: 'foo' # Looks like you failed 1 tests of 7.
TIP
The include statement at the beginning of Listing 15-1 is optional, but it makes the test file an independent PHP script that you can execute without the symfony command line, by calling php test/unit/strtolowerTest.php.
The lime_test object comes with a large number of testing methods, as listed in Table 15-2.
Table 15-2 - Methods of the lime_test Object for Unit Testing
| Method | Description |
|---|---|
diag($msg) |
Outputs a comment but runs no test |
ok($test, $msg) |
Tests a condition and passes if it is true |
is($value1, $value2, $msg) |
Compares two values and passes if they are equal (==) |
isnt($value1, $value2, $msg) |
Compares two values and passes if they are not equal |
like($string, $regexp, $msg) |
Tests a string against a regular expression |
unlike($string, $regexp, $msg) |
Checks that a string doesn't match a regular expression |
cmp_ok($value1, $operator, $value2, $msg) |
Compares two arguments with an operator |
isa_ok($variable, $type, $msg) |
Checks the type of an argument |
isa_ok($object, $class, $msg) |
Checks the class of an object |
can_ok($object, $method, $msg) |
Checks the availability of a method for an object or a class |
is_deeply($array1, $array2, $msg) |
Checks that two arrays have the same values |
include_ok($file, $msg) |
Validates that a file exists and that it is properly included |
fail() |
Always fails--useful for testing exceptions |
pass() |
Always passes--useful for testing exceptions |
skip($msg, $nb_tests) |
Counts as $nb_tests tests--useful for conditional tests |
todo() |
Counts as a test--useful for tests yet to be written |
The syntax is quite straightforward; notice that most methods take a message as their last parameter. This message is displayed in the output when the test passes. Actually, the best way to learn these methods is to test them, so have a look at Listing 15-3, which uses them all.
Listing 15-3 - Testing Methods of the lime_test Object, in test/unit/exampleTest.php
<?php include(dirname(__FILE__).'/../bootstrap/unit.php'); // Stub objects and functions for test purposes class myObject { public function myMethod() { } } function throw_an_exception() { throw new Exception('exception thrown'); } // Initialize the test object $t = new lime_test(16, new lime_output_color()); $t->diag('hello world'); $t->ok(1 == '1', 'the equal operator ignores type'); $t->is(1, '1', 'a string is converted to a number for comparison'); $t->isnt(0, 1, 'zero and one are not equal'); $t->like('test01', '/test\d+/', 'test01 follows the test numbering pattern'); $t->unlike('tests01', '/test\d+/', 'tests01 does not follow the pattern'); $t->cmp_ok(1, '<', 2, 'one is inferior to two'); $t->cmp_ok(1, '!==', true, 'one and true are not identical'); $t->isa_ok('foobar', 'string', '\'foobar\' is a string'); $t->isa_ok(new myObject(), 'myObject', 'new creates object of the right class'); $t->can_ok(new myObject(), 'myMethod', 'objects of class myObject do have a myMethod method'); $array1 = array(1, 2, array(1 => 'foo', 'a' => '4')); $t->is_deeply($array1, array(1, 2, array(1 => 'foo', 'a' => '4')), 'the first and the second array are the same'); $t->include_ok('./fooBar.php', 'the fooBar.php file was properly included'); try { throw_an_exception(); $t->fail('no code should be executed after throwing an exception'); } catch (Exception $e) { $t->pass('exception catched successfully'); } if (!isset($foobar)) { $t->skip('skipping one test to keep the test count exact in the condition', 1); } else { $t->ok($foobar, 'foobar'); } $t->todo('one test left to do');
You will find a lot of other examples of the usage of these methods in the symfony unit tests.
TIP
You may wonder why you would use is() as opposed to ok() here. The error message output by is() is much more explicit; it shows both members of the test, while ok() just says that the condition failed.
The initialization of the lime_test object takes as its first parameter the number of tests that should be executed. If the number of tests finally executed differs from this number, the lime output warns you about it. For instance, the test set of Listing 15-3 outputs as Listing 15-4. The initialization stipulated that 16 tests were to run, but only 15 actually took place, so the output indicates this.
Listing 15-4 - The Count of Test Run Helps You to Plan Tests
> php symfony test:unit example 1..16 # hello world ok 1 - the equal operator ignores type ok 2 - a string is converted to a number for comparison ok 3 - zero and one are not equal ok 4 - test01 follows the test numbering pattern ok 5 - tests01 does not follow the pattern ok 6 - one is inferior to two ok 7 - one and true are not identical ok 8 - 'foobar' is a string ok 9 - new creates object of the right class ok 10 - objects of class myObject do have a myMethod method ok 11 - the first and the second array are the same not ok 12 - the fooBar.php file was properly included # Failed test (.\test\unit\testTest.php at line 27) # Tried to include './fooBar.php' ok 13 - exception catched successfully ok 14 # SKIP skipping one test to keep the test count exact in the condition ok 15 # TODO one test left to do # Looks like you planned 16 tests but only ran 15. # Looks like you failed 1 tests of 16.
The diag() method doesn't count as a test. Use it to show comments, so that your test output stays organized and legible. On the other hand, the todo() and skip() methods count as actual tests. A pass()/fail() combination inside a try/catch block counts as a single test.
A well-planned test strategy must contain an expected number of tests. You will find it very useful to validate your own test files--especially in complex cases where tests are run inside conditions or exceptions. And if the test fails at some point, you will see it quickly because the final number of run tests won't match the number given during initialization.
The second parameter of the constructor is an output object extending the lime_output class. Most of the time, as tests are meant to be run through a CLI, the output is a limeoutputcolor object, taking advantage of bash coloring when available.
The test:unit task, which launches unit tests from the command line, expects either a list of test names or a file pattern. See Listing 15-5 for details.
Listing 15-5 - Launching Unit Tests
// Test directory structure
test/
unit/
myFunctionTest.php
mySecondFunctionTest.php
foo/
barTest.php
> php symfony test:unit myFunction ## Run myFunctionTest.php
> php symfony test:unit myFunction mySecondFunction ## Run both tests
> php symfony test:unit 'foo/*' ## Run barTest.php
> php symfony test:unit '*' ## Run all tests (recursive)
In a unit test, the autoloading feature is not active by default. Each class that you use in a test must be either defined in the test file or required as an external dependency. That's why many test files start with a group of include lines, as Listing 15-6 demonstrates.
Listing 15-6 - Including Classes in Unit Tests
<?php include(dirname(__FILE__).'/../bootstrap/unit.php'); require_once($sf_symfony_lib_dir.'/util/sfToolkit.class.php'); $t = new lime_test(7, new lime_output_color()); // isPathAbsolute() $t->diag('isPathAbsolute()'); $t->is(sfToolkit::isPathAbsolute('/test'), true, 'isPathAbsolute() returns true if path is absolute'); $t->is(sfToolkit::isPathAbsolute('\\test'), true, 'isPathAbsolute() returns true if path is absolute'); $t->is(sfToolkit::isPathAbsolute('C:\\test'), true, 'isPathAbsolute() returns true if path is absolute'); $t->is(sfToolkit::isPathAbsolute('d:/test'), true, 'isPathAbsolute() returns true if path is absolute'); $t->is(sfToolkit::isPathAbsolute('test'), false, 'isPathAbsolute() returns false if path is relative'); $t->is(sfToolkit::isPathAbsolute('../test'), false, 'isPathAbsolute() returns false if path is relative'); $t->is(sfToolkit::isPathAbsolute('..\\test'), false, 'isPathAbsolute() returns false if path is relative');
In unit tests, you need to instantiate not only the object you're testing, but also the object it depends upon. Since unit tests must remain unitary, depending on other classes may make more than one test fail if one class is broken. In addition, setting up real objects can be expensive, both in terms of lines of code and execution time. Keep in mind that speed is crucial in unit testing because developers quickly tire of a slow process.
Whenever you start including many scripts for a unit test, you may need a simple autoloading system. For this purpose, the sfSimpleAutoload class (which must be manually included) provides an addDirectory() method which expects an absolute path as parameter and that can be called several times in case you need to include several directories on the search path. All the classes located under this path will be autoloaded. For instance, if you want to have all the classes located under $sf_symfony_lib_dir/util/ autoloaded, start your unit test script as follows:
require_once($sf_symfony_lib_dir.'/autoload/sfSimpleAutoload.class.php'); $autoload = new sfSimpleAutoload(); $autoload->addDirectory($sf_symfony_lib_dir.'/util'); $autoload->register();
Another good workaround for the autoloading issues is the use of stubs. A stub is an alternative implementation of a class where the real methods are replaced with simple canned data. It mimics the behavior of the real class, but without its cost. A good example of stubs is a database connection or a web service interface. In Listing 15-7, the unit tests for a mapping API rely on a WebService class. Instead of calling the real fetch() method of the actual web service class, the test uses a stub that returns test data.
Listing 15-7 - Using Stubs in Unit Tests
require_once(dirname(__FILE__).'/../../lib/WebService.class.php'); require_once(dirname(__FILE__).'/../../lib/MapAPI.class.php'); class testWebService extends WebService { public static function fetch() { return file_get_contents(dirname(__FILE__).'/fixtures/data/fake_web_service.xml'); } } $myMap = new MapAPI(); $t = new lime_test(1, new lime_output_color()); $t->is($myMap->getMapSize(testWebService::fetch(), 100));
The test data can be more complex than a string or a call to a method. Complex test data is often referred to as fixtures. For coding clarity, it is often better to keep fixtures in separate files, especially if they are used by more than one unit test file. Also, don't forget that symfony can easily transform a YAML file into an array with the sfYAML::load() method. This means that instead of writing long PHP arrays, you can write your test data in a YAML file, as in Listing 15-8.
Listing 15-8 - Using Fixture Files in Unit Tests
// In fixtures.yml: - input: '/test' output: true comment: isPathAbsolute() returns true if path is absolute - input: '\\test' output: true comment: isPathAbsolute() returns true if path is absolute - input: 'C:\\test' output: true comment: isPathAbsolute() returns true if path is absolute - input: 'd:/test' output: true comment: isPathAbsolute() returns true if path is absolute - input: 'test' output: false comment: isPathAbsolute() returns false if path is relative - input: '../test' output: false comment: isPathAbsolute() returns false if path is relative - input: '..\\test' output: false comment: isPathAbsolute() returns false if path is relative // In testTest.php <?php include(dirname(__FILE__).'/../bootstrap/unit.php'); require_once($sf_symfony_lib_dir.'/util/sfToolkit.class.php'); require_once($sf_symfony_lib_dir.'/yaml/sfYaml.class.php'); $testCases = sfYaml::load(dirname(__FILE__).'/fixtures.yml'); $t = new lime_test(count($testCases), new lime_output_color()); // isPathAbsolute() $t->diag('isPathAbsolute()'); foreach ($testCases as $case) { $t->is(sfToolkit::isPathAbsolute($case['input']), $case['output'],$case['comment']); }
Testing Propel classes is a bit more involving as the generated Propel objects rely on a long cascade of classes. Moreover, you need to provide a valid database connection to Propel and you also need to feed the database with some test data.
Thankfully, it is quite easy as symfony already provides everything you need:
sfDatabaseManager classsfPropelData classA typical Propel test file is shown in Listing 15-9.
Listing 15-9 - Testing Propel classes
<?php include(dirname(__FILE__).'/../bootstrap/unit.php'); new sfDatabaseManager(ProjectConfiguration::getApplicationConfiguration('frontend', 'test', true)); $loader = new sfPropelData(); $loader->loadData(sfConfig::get('sf_data_dir').'/fixtures'); $t = new lime_test(1, new lime_output_color()); // begin testing your model class $t->diag('->retrieveByUsername()'); $user = UserPeer::retrieveByUsername('fabien'); $t->is($user->getLastName(), 'Potencier', '->retrieveByUsername() returns the User for the given username');