45
Test-Driven Development Francis Fish, Pharmarketeer francis.fish@pharmarketeer. com This is distributed under the Creative Commons Attribution-Share Alike 2.0 licence Download from: http://www.pharmarketeer.com/tdd.html

Test driven development_for_php

Embed Size (px)

DESCRIPTION

Training material I used to introduce Test Driven Development to PHP programmers for one of my clients.

Citation preview

Page 1: Test driven development_for_php

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

Page 2: Test driven development_for_php

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

Page 3: Test driven development_for_php

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.

Page 4: Test driven development_for_php

Example: Specification

The function add() should add two numbers

Page 5: Test driven development_for_php

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).

Page 6: Test driven development_for_php

Let's Fail!

test_add.php: <?phprequire_once 'PHPUnit/Framework.php' ;require_once('add.php');

class TestOfAdd extends PHPUnit_Framework_TestCase {     } 

Page 7: Test driven development_for_php

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.

Page 8: Test driven development_for_php

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.

Page 9: Test driven development_for_php

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

Page 10: Test driven development_for_php

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

Page 11: Test driven development_for_php

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.

Page 12: Test driven development_for_php

next ...

What happens when you don't pass numbersWhat happens when you don't pass enough arguments

Discuss.

Page 13: Test driven development_for_php

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.

Page 14: Test driven development_for_php

Data Object specification

1. Instantiate from a name/value map2. Return attribute values for given name3. Handle capitalisation of attribute names

Page 15: Test driven development_for_php

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");  }}?>

Page 16: Test driven development_for_php

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!

Page 17: Test driven development_for_php

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.

Page 18: Test driven development_for_php

Discussion - what have we missed?

1. Instantiate from a name/value map2. Return attribute values for given name3. Handle capitalisation of attribute names

Page 19: Test driven development_for_php

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.

Page 20: Test driven development_for_php

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

Page 21: Test driven development_for_php

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

Page 22: Test driven development_for_php

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

Page 23: Test driven development_for_php

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.

Page 24: Test driven development_for_php

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 ;  }}

Page 25: Test driven development_for_php

Advanced example - mock and stub

 

Page 26: Test driven development_for_php

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

Page 27: Test driven development_for_php

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

Page 28: Test driven development_for_php

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

Page 29: Test driven development_for_php

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.

Page 30: Test driven development_for_php

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.

Page 31: Test driven development_for_php

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

Page 32: Test driven development_for_php

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.

Page 33: Test driven development_for_php

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

Page 34: Test driven development_for_php

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:

Page 35: Test driven development_for_php

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 ...

}

Page 36: Test driven development_for_php

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

Page 37: Test driven development_for_php

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.

Page 38: Test driven development_for_php

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.

Page 39: Test driven development_for_php

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

Page 40: Test driven development_for_php

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

Page 41: Test driven development_for_php

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

Page 42: Test driven development_for_php

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.

Page 43: Test driven development_for_php

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.

Page 44: Test driven development_for_php

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.

Page 45: Test driven development_for_php

BANG!

We Failed!