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.
15.2.1. What Do Unit Tests Look Like?
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
.
15.2.2. Unit Testing Methods
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.
15.2.3. Testing Parameters
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 lime_output_color object, taking advantage of bash coloring when available.
15.2.4. The test:unit Task
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)
15.2.5. Stubs, Fixtures, and Autoloading
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']);
}
15.2.6. Unit testing Propel classes
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:
- To get autoloading, you need to initialize a configuration object
- To get a database connection, you need to initialize the
sfDatabaseManager
class - To load some test data, you can use the
sfPropelData
class
A 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');