Test-Driven Development
Francis Fish, [email protected]
This is distributed under the Creative Commons Attribution-Share Alike 2.0 licence
Download from: http://www.pharmarketeer.com/tdd.html
What?
Nutshell:
Write test before developing codeWrite code that meets the bare minumum for the testWrite the next test http://en.wikipedia.org/wiki/Test-driven_development http://www.slideshare.net/Skud/test-driven-development-tutorial
aka - Behaviour Driven Development BDD
Why do TDD?
• Shortfalls/stable/maintenance• 90% cost of software is maintenance - make maintenance and
change easy and low cost• Legacy code is untested code (as in repeated automated tests that
can be used for regression testing)• Stress requirements - find weaknesses and misunderstandings
sooner rather than later. Reducing QA, cheaper - stressing specification - NO FUDGING - find interpretation errors early.
• Common language to talk about tests. Testers can even inspect the tests as part of the delivery.
Example: Specification
The function add() should add two numbers
First test case
We are going to use PHPUnit 3.4.3. http://www.phpunit.de Download from
http://pear.phpunit.de/get/ This can be installed using PEAR (see instructions from http://pear.phpunit.de).
Let's Fail!
test_add.php: <?phprequire_once 'PHPUnit/Framework.php' ;require_once('add.php');
class TestOfAdd extends PHPUnit_Framework_TestCase { }
We Failed!
$ phpunit add_test.php
Fatal error: require_once(): Failed opening required 'add.php' (include_path='D:\PHP;.;D:\PHP\PEAR;d:\php\includes;d:\apache\classes;d:\apache\conf;d:\apache\includes') in D:\devwork\tdd_dev\test_add.php on line 5
This is a contrived example - we haven't created the class yet.
Add the empty class
create add.php
$ phpunit.bat test_add.php PHPUnit 3.4.3 by Sebastian Bergmann.FTime: 1 secondThere was 1 failure:1) WarningNo tests found in class "TestOfAdd".FAILURES!Tests: 1, Assertions: 0, Failures: 1.
Now we've made the point about test first, assume we have a file with an empty class.
Add a test to the test class
class TestOfAdd extends PHPUnit_Framework_TestCase { function testAddAddsNumbers() { $add = new Add(); $this->assertEquals($add->do_add(1,2),3); }}
Fatal error: Call to undefined method Add::do_add() in D:\devwork\tdd_dev\test_add.php on line 11
Add the method
add.php: class Add { function do_add($one,$other) { return $one + $other ; }}
.Time: 0 secondsOK (1 test, 1 assertion)add_test.phpOKTest cases run: 1/1, Passes: 1, Failures: 0, Exceptions: 0
Painful, wasn't it?
BUT:
• You know every method has at least one test (albeit maybe naive)• You know you can introduce changes without breaking any
existing code• You write small, well-focussed methods• You start from the specification and turn it into code• So you know you've met the spec as far as you understood it.
next ...
What happens when you don't pass numbersWhat happens when you don't pass enough arguments
Discuss.
Designing a data object
We want to be able to write code like this: $db = new DB("some config info"); $client = $db -> getRow("select first_name, last_name from clients", Array( 'id' => 6)) ;echo $client->first_name ;
This needs a data class that is returned by the database helper.
The data class needs to take an array of returned data and respond to method calls that ask for the data.
Data Object specification
1. Instantiate from a name/value map2. Return attribute values for given name3. Handle capitalisation of attribute names
Data Object tests
1. Instantiate from a name/value map2. Return attribute values for given name
require_once('PHPUnit/Framework.php');require_once('data_obj.php');
class TestBasicDataObj extends PHPUnit_Framework_TestCase { // Simple case - does it work function testDataObj() { $data_obj = new DataObj(Array("field1" => 1, "field2" => "2")); $this->assertEquals($data_obj->field1,1); $this->assertEquals($data_obj->field2,"2"); }}?>
data_obj.php
<?phpclass DataObj{}?>
This does nothing at the moment $ phpunit.bat TestBasicDataObj.phpPHPUnit 3.4.3 by Sebastian Bergmann.
F
Time: 1 second
There was 1 failure:
1) TestBasicDataObj::testDataObjFailed asserting that <integer:1> matches expected <null>.
D:\devwork\tdd_dev\TestBasicDataObj.php:12
FAILURES!
Add some methods to the class
class DataObj{ public $data = Array() ; function __construct($data) { foreach ( $data as $key => $value ) { $this->data[strtolower($key)] = $value ; } } function __get($name) { $idx = strtolower($name) ; if ( isset($this->data[$idx]) ) return $this->data[$idx]; return null ; }}
Here we use the "magic methods" to give us a class that responds to what we want.
Discussion - what have we missed?
1. Instantiate from a name/value map2. Return attribute values for given name3. Handle capitalisation of attribute names
Test mixed case
class TestBasicDataObj extends UnitTestCase { private $test_init = Array("field1" => 1, "field2" => "2"); private $data_obj = null; // This is the per-test setup function setUp() { $this->data_obj = new DataObj($this->test_init); } // Simple case - does it work function testDataObj() { $this->assertEquals($this->data_obj->field1, 1); $this->assertEquals($this->data_obj->field2, "2"); } // Simple case - does do mixed case function testDataObjMixedCase() { $this->assertEquals($this->data_obj->Field1, 1); $this->assertEquals($this->data_obj->fielD2,"2"); }}
This shows how to set up common data for tests - there is an equivalent teardown method too.
Aside: Meaningful messages$ php test1/data_obj_test2.phpdata_obj_test2.phpOKTest cases run: 1/1, Passes: 4, Failures: 0, Exceptions: 0
Let's make it fail$this->assertEquals($this->data_obj->field1, 99)...1) TestBasicDataObj::testDataObjFailed asserting that <integer:99> matches expected <integer:1>.
This message isn't very good $this->assertEquals($this->data_obj->field1 , 99,"Field 1 invalid value")...1) TestBasicDataObj::testDataObjfield 1 invalid valueFailed asserting that <integer:99> matches expected <integer:1>.data_obj_test2.php
This gives a much better error message, aside from field 1 being a "magic spell" name
Discussion - what have we missed?
1. Instantiate from a name/value map2. Return attribute values for given name3. Handle capitalisation of attribute names 4. Throw exception if asked for invalid attribute
ExceptionsAsking for an attribute that isn't there is an error and should raise an exception:
// Validate it gets upset when you ask for an invalid attribute function testDataObjInvalidAttribute() { $this->setExpectedException( 'Exception',"Invalid data object attribute" ); $this->data_obj->missing_field ; }...
We are expecting an exception with a particular message:..FTime: 1 secondThere was 1 failure:1) TestBasicDataObj::testDataObjInvalidAttributeExpected exception ExceptionFAILURES!Tests: 3, Assertions: 5, Failures: 1
Fix the code
function __get($name) { $idx = strtolower($name) ; if ( isset($this->data[$idx]) ) return $this->data[$idx]; throw new Exception("Invalid data object attribute" ); }
...
PHPUnit 3.4.3 by Sebastian Bergmann.
...
Time: 1 second
OK (3 tests, 6 assertions)
Note that you can expect error messages as well.
Recap: the test class
require_once 'PHPUnit/Framework.php' ;require_once('data_obj.php');
class TestBasicDataObj extends PHPUnit_Framework_TestCase { private $test_init = Array("field1" => 1, "field2" => "2"); private $data_obj = null; // This is the per-test setup function setUp() { $this->data_obj = new DataObj($this->test_init); } // Simple case - does it work function testDataObj() { $this->assertEquals($this->data_obj->field1, 99,"field 1 invalid value"); $this->assertEquals($this->data_obj->field2, "2"); } // Simple case - does do mixed case function testDataObjMixedCase() { $this->assertEquals($this->data_obj->Field1, 1); $this->assertEquals($this->data_obj->fielD2, "2"); }
function testDataObjInvalidAttribute() { $this->setExpectedException( 'Exception',"Invalid data object attribute" ); $this->data_obj->missing_field ; }}
Advanced example - mock and stub
Mocks and Stubs
• A Mock - pretends to be a collaborating object and allows you to create responses for that object. o Mocks have expectations. You can say that you expect a given
method to be called as part of the test (and even how many times).
• Stub - Override a given method or class and return fixed responses.o Stubs don't have expectations. Tests assert responses from the
object under test are correct given the stub.
http://martinfowler.com/articles/mocksArentStubs.html
We don' need no stinkin' datybasey
Let's take a step back and think about the class that will be returning the simple data object (or arrays of them, depending).
Change parameters into some SQL
Get "stuff"
Return data object
DB Class "formal" specification
getRow() method:1. Returns a DataObj class– Returns a DataObj class with relevant data– Returns a DataObj populated from the database output – Sends the correct SQL to the database
Discussion
We want this: $db = new DB("some config info"); $client = $db -> getRow("select first_name, last_name from clients", Array( 'id' => 6)) ;echo $client->first_name ;
So ... something like this to start: function testGetRowReturnsDataObj(){ $db = new DB("something"); $client = $db -> getRow("select first_name, last_name from clients", Array( 'id' => 6)) ; $this->isInstanceOf($client,DataObj); }
Check we get a DataObj back.
Only do what the test asks
<?phprequire_once('data_obj.php');
class DB { function getRow($sql,$bind_args = array()){ return new DataObj(array()); }}
The test, the test and nothing but the test - we aren't even using the constructor.
DB Class "formal" specification
getRow() method:1. Returns a DataObj class– Returns a DataObj class with relevant data– Returns a DataObj populated from the database output – Sends the correct SQL to the database
Get some data back
function testGetRowGivesCorrectValues(){ $db = new DB("something"); $client = $db -> getRow("select first_name, last_name from clients", Array( 'id' => 6)) ; $this->assertEquals($client->first_name, "some constant we know", "Client first name not correct"); }...There was 1 error:
1) TestDbObj::testGetRowGivesCorrectValuesException: Invalid data object attribute
This is the DataObj complaining about not being initialised with a value.
Change the class
class DB { function getRow($sql,$bind_args = array()){ return new DataObj(array("first_name" => "some constant we know")); }}
Again the bare minimum
Mock the database
Assume that the DB class has a database access object set in the constructor. Let's create a mock for the existing database access wrapper (OraDB, say) and tidy up the repetition:
require_once 'PHPUnit/Framework.php' ;require_once('db.php');// Note that the constructor has been hacked because it tries to instantiate a db connectionrequire_once('./Oradb.php');
class TestDbObj extends PHPUnit_Framework_TestCase {
private $db_handler = null ; private $db = null ; private $client = null ; // Set up the tests function setUp(){ $this->db_handler = $this -> getMock('Oradb'); $this->db = new DB($this->db_handler); } // helper function get_client() { $this->client = $this->db -> getRow("select first_name, last_name from clients", Array( 'id' => 6)) ; } // Naive test of get row function function testGetRowReturnsDataObj(){ $this->get_client(); $this->isInstanceOf($this->client,DataObj); } // ... etc ...
}
DB Class "formal" specification
getRow() method:1. Returns a DataObj class– Returns a DataObj class with relevant data– Returns a DataObj populated from the database output – Sends the correct SQL to the database
Stub database calls
// Stub out the return from oSelect - have to create an entirely new stub function testGetRowHandlesReturnArray(){ $this->db_handler = $this -> getMock('Oradb'); $this->db_handler->expects($this->any()) ->method('oselect') ->will($this->returnValue(array('first_name' => "some other constant we know"))); $this->db = new DB($this->db_handler); $this->get_client(); $this->assertEquals($this->client->first_name, "some other constant we know", "Client first name not correct"); }
This is a stub call - just handing back a constant argument - note that the function name is all lower case.
Change the class
class DB{
private $db = null ; function __construct($db) { $this->db = $db ; }
function getRow($sql,$bind_args = array()){ $data = $this->db->oselect($sql,$rval) ; return new DataObj($data); }}
Now we are using the oselect method in our code. Note that it expects the correct number of arguments for the stub.
DB Class "formal" specification
getRow() method:1. Returns a DataObj class– Returns a DataObj class with relevant data– Returns a DataObj populated from the database output – Sends the correct SQL to the database
Check getRow() SQL generation
// Make sure that getRow parses its arguments into some correct-seeming SQL function testGetRowSendsCorrectSQL(){ $this->db_handler->expects($this->once()) ->method('oselect') ->with($this->equalTo('select first_name, last_name from clients where id = 6')) ->will($this->returnValue(array('first_name' => "some other constant we know"))); $this->db = new DB($this->db_handler); $this->get_client(); }...
1) TestDbObj::testGetRowSendsCorrectSQLFailed asserting that two strings are equal.--- Expected+++ Actual@@ @@-select first_name, last_name from clients where id = 6+select first_name, last_name from clients
D:\devwork\tdd_dev\db.php:14D:\devwork\tdd_dev\TestDbObj.php:26D:\devwork\tdd_dev\TestDbObj.php:57
Fix the class
require_once('classes/data_obj.php');
class DB { private $db = null ; function __construct($db) { $this->db = $db ; }
function getRow($sql,$bind_args = array()){ $query = $sql ; $delimiter = 'where' ; foreach ( $bind_args as $key => $value ) { $query .= " $delimiter $key = $value " ; $delimiter = 'and' ; } $data = $this->db->oselect($query,$rval) ; return new DataObj($data); }}... This fails!!1) TestDbObj::testGetRowSendsCorrectSQLFailed asserting that two strings are equal.--- Expected+++ Actual@@ @@-select first_name, last_name from clients where id = 6+select first_name, last_name from clients where id = 6
Why did it fail?
The method in the DB class leaves a trailing space at the end of the string. If you add the trailing space to the expectations it will succeed.
This is a very brittle test. If you use a pattern instead it could work, but there seems to be no pattern expectation available as the assert pattern method needs two arguments.
This is still a brittle test, and needs some more thought. Discuss.
The results of more thought
It would be better to put the creation of the SQL into its own function that can be tested independently. This is one of the ways that TDD drives you to make better decisions about the structure of the code, forcing a change like this will make it more reusable.
Where next?
The DB class needs to use bind variables. It will explode if you pass it strings, for example.
Developing it more needs to mock out methods likebindstart and bindadd.
Note that the way PHPUnit does mocking it tries to call a no-args constructor, in the case of our oradb class it tried to set up a database connection. In order to get these examples to work I made a copy of the class and commented out the body of the constructor. A more sensible way of doing this is to create a static method puts the class in "test mode" that makes the constructor do nothing.
BANG!
We Failed!