Unit testing seems like a bit of a dark art when you’re first introduced to it. “Create this new file. Tell it what is supposed to be the result when you run a test, and it’ll tell you if you’re right nor not.”
Let’s start with a pseudocode example:
test->assertTrue(1+1 = 2); // Test returns true, huzzah! test->assertFalse(1+1 = 3); // Test returns false. Those integers must not have been large enough
I want to use PHPUnit, and for me the easiest way to get this and the rest of the tools I’ll be referring to in this collection of posts is to install “The PHP Quality Assurance Toolchain“. On my Ubuntu install, this was done as follows:
sudo pear upgrade PEAR sudo pear config-set auto_discover 1 sudo pear install --all-deps pear.phpqatools.org/phpqatools
Now we’ve got the tools in place, let’s set up the directory structure.
/ + -- Classes | + -- Config.php + -- Tests + -- ConfigTest.php
In here, you see we’ve created two files, one contains the class we want to use, and the other contains the tests we will be running.
So, let’s slap on the veneer of coating that these two files need to be valid to test.
/Classes/Config.php
<?php class Config { }
/Tests/Config.php
<?php include dirname(__FILE__) . '/../Classes/Config.php'; class ConfigTest extends PHPUnit_Framework_TestCase { }
So, just to summarise, here we have two, essentially empty classes.
Let’s put some code into the test file.
<?php include dirname(__FILE__) . '/../Classes/Config.php'; class ConfigTest extends PHPUnit_Framework_TestCase { public function testCreateObject() { $config = new Config(); $this->assertTrue(is_object($config)); } }
We can now run this test from the command line as follows:
phpunit Tests/ConfigTest.php
phpunit Tests/01_ConfigTest.php PHPUnit 3.6.10 by Sebastian Bergmann. . Time: 1 second, Memory: 3.00Mb OK (1 test, 1 assertion)
That was nice and straightforward!
Let’s add some more code!
In ConfigTest, let’s tell it to load some configuration, using a config file.
<?php include dirname(__FILE__) . '/../Classes/Config.php'; class ConfigTest extends PHPUnit_Framework_TestCase { public function testCreateObject() { $config = new Config(); $this->assertTrue(is_object($config)); } public function testLoadConfig() { $config = new Config(); $config->load(); } }
And now when we run it?
PHP Fatal error: Call to undefined method Config::load() in /var/www/PhpBetterPractices/Tests/ConfigTest.php on line 16
Ah, perhaps we need to write some code into /Classes/Config.php
<?php class Config { public function load() { include dirname(__FILE__) . '/../Config/default_config.php'; } }
But, running this, again, we get an error message!
PHPUnit 3.6.10 by Sebastian Bergmann. .E Time: 0 seconds, Memory: 3.00Mb There was 1 error: 1) ConfigTest::testLoadConfig include(/var/www/PhpBetterPractices/Config/default_config.php): failed to open stream: No such file or directory /var/www/PhpBetterPractices/Classes/Config.php:7 /var/www/PhpBetterPractices/Classes/Config.php:7 /var/www/PhpBetterPractices/Tests/ConfigTest.php:16 FAILURES! Tests: 2, Assertions: 1, Errors: 1.
So, we actually need to check that the file exists first, perhaps we should throw an error if it doesn’t? We could also pass the name of the config file to pass to the script, which would let us test more and different configuration options, should we need them.
class Config { public function load($file = null) { if ($file == null) { $file = 'default.config.php'; } $filename = dirname(__FILE__) . '/../Config/' . $file; if (file_exists($filename)) { include $filename; } else { throw new InvalidArgumentException("File not found"); } } }
So, here’s the new UnitTest code:
class ConfigTest extends PHPUnit_Framework_TestCase { public function testCreateObject() { $config = new Config(); $this->assertTrue(is_object($config)); } public function testLoadConfig() { $config = new Config(); $config->load(); } /** * @expectedException InvalidArgumentException */ public function testFailLoadingConfig() { $config = new Config(); @$config->load('A file which does not exist'); } }
This assumes the file /Config/default.config.php exists, albeit as an empty file.
So, let’s run those tests and see what happens?
PHPUnit 3.6.10 by Sebastian Bergmann. ... Time: 0 seconds, Memory: 3.25Mb OK (3 tests, 2 assertions)
Huzzah! That’s looking good. Notice that to handle a test of something which should throw an exception, you can either wrapper the function in a try/catch loop and, in the try side of the loop, have $this->assertTrue(false) to prevent false positives and in the catch side, do your $this->assertBlah() on the exception. Alternatively, (and much more simplely), use a documentation notation of @expectedException NameOfException and then prefix the function you are testing with the @ symbol. This is how I did it with the test “testFailLoadingConfig()”.
This obviously doesn’t handle setting and getting configuration values, so let’s add those.
Here’s the additions to the Config.php file:
public function set($key = null, $value = null) { if ($key == null) { throw new BadFunctionCallException("Key not set"); } if ($value == null) { unset ($this->arrValues[$key]); return true; } else { $this->arrValues[$key] = $value; return true; } } public function get($key = null) { if ($key == null) { throw new BadFunctionCallException("Key not set"); } if (isset($this->arrValues[$key])) { return $this->arrValues[$key]; } else { return null; } }
And the default.config.php file:
<?php $this->set('demo', true);
And lastly, the changes to the ConfigTest.php file:
public function testLoadConfig() { $config = new Config(); $this->assertTrue(is_object($config)); $config->load('default.config.php'); $this->assertTrue($config->get('demo')); } /** * @expectedException BadFunctionCallException */ public function testFailSettingValue() { $config = new Config(); @$config->set(); } /** * @expectedException BadFunctionCallException */ public function testFailGettingValue() { $config = new Config(); @$config->get(); }
We’ve not actually finished testing this yet. Not sure how I can tell?
phpunit --coverage-text Tests/ConfigTest.php PHPUnit 3.6.10 by Sebastian Bergmann. .... Time: 0 seconds, Memory: 3.75Mb OK (4 tests, 5 assertions) Generating textual code coverage report, this may take a moment. Code Coverage Report 2012-05-08 18:54:16 Summary: Classes: 0.00% (0/1) Methods: 0.00% (0/3) Lines: 76.19% (16/21) @Config::Config Methods: 100.00% ( 3/ 3) Lines: 76.19% ( 16/ 21)
Notice that there are 5 lines outstanding – probably around the unsetting values and using default values. If you use an IDE (like NetBeans) you can actually get the editor to show you, using coloured lines, exactly which lines you’ve not yet tested! Nice.
So, the last thing to talk about is Containers and Dependency Injection. We’ve already started with the Dependency Injection here – that $config->load(‘filename’); function handles loading config files, or you could just bypass that with $config->set(‘key’, ‘value); but once you get past a file or two, you might just end up with a lot of redundant re-loading of config files, or worse, lots of database connections open.
So, this is where Containers come in (something I horrifically failed to understand before).
Here’s a container:
class ConfigContainer { protected static $config = null; public static function Load() { if (self::$config == null) { self::$config = new Config(); self::$config->load(); } return self::$Config; } }
It’s purpose (in this case) is to load the config class, including any dependencies that you may need for that class, and then return that class to you. You could conceivably create a Database container, or a Request container or a User container with very little extra work, and with a few short calls, have a single function for each of your regular and routine sources of processing data, but without preventing you from being able to easily and repeatably test that data – by not going through the container.
Of course, there’s nothing to stop you just having these created in a registry class, or store them in a global from the get-go, but, I am calling these “Better Practices” after all, and these are considered to be not-so-good-practices.
Just as a note, code from this section can be seen at GitHub, if you want to use them at all.
Update 2012-05-11: Added detail to the try/catch exception catching as per frimkron’s comment. Thanks!