php|tek TDD live code

Many thanks to everyone who attended my Test Driven Development tutorial today at php|tek in Chicago. As promised, here is the code we developed during the live coding sections of the tutorial.

The slides I presented are available from the php|tek site here.

Our first example was a simple Hash object. The requirements we developed for the hash were:
***Hash to repond to get(key) and set(key, value)
***Hash to have isValid(key)
***load from associative array at construction
access via object notation $hash->key or $hash->key = value
***access via array notation $hash[key] or $hash[key] = value

During our session, we covered 4 out of the 5 requirements using TDD.

Here was the test case we ended up with for our Hash object:

<?php

class HashTestCase extends BaseTestCase {
    function testClass() {
        $this->assertTrue(class_exists('Hash'));
    }
    
    function testGetAndSet() {
        $hash = new Hash;
        $this->assertMethodExists($hash, 'get');
        $this->assertMethodExists($hash, 'set');
        
        $hash->set('foo', 'bar');
        $this->assertEqual('bar', $hash->get('foo'));
        $hash->set('baz', 'blah');
        $this->assertEqual('blah', $hash->get('baz'));
    }
    
    function testIsValid() {
        $hash = new Hash;
        $this->assertMethodExists($hash, 'isValid');
        
        $this->assertFalse($hash->isValid('foo'));
        $hash->set('foo', 'bar');
        $this->assertTrue($hash->isValid('foo'));
    }

    function testLoadDuringConstruction() {
        $arr = array('foo' => 'bar');
        $hash = new Hash($arr);
        
        $this->assertEqual('bar', $hash->get('foo'));
        
        $hash2 = new Hash('false');
        $hash2->set('foo','bar');
        $this->assertEqual('bar', $hash2->get('foo'));
    }
    
    function testAccessAsArray() {
        $hash = new Hash(array('foo' => 'bar'));
        $this->assertEqual('bar', $hash['foo']);
    }

}

?>

The code we developed for the Hash class was:

<?php

class Hash implements ArrayAccess {
    function __construct($vals = false) {
        $this->vals = (is_array($vals)) ? $vals : array();
    }
    protected $vals = array();
    function get($key) {
        return $this->vals[$key];
    }
    function set($key, $value) {
        $this->vals[$key] = $value;
    }
    function isValid($key) {
        return array_key_exists($key, $this->vals);
    }
    // functions to implement ArrayAccess
    function offsetExists($key) {
        return $this->isValid($key);
    }
    function offsetGet($key) {
        return $this->get($key);
    }
    function offsetSet($key, $val) {
        $this->set($key, $val);
    }
    function offsetUnset($key) {
        unset($this->vals[$key]);
    }
}

?>

After we reviewed more of the benefits of using TDD, we move on to a more meaty application: a guest book. Here was the test cases we developed:

<?php

class BaseTestCase extends UnitTestCase {
    function assertMethodExists($object, $method) {
        $this->assertTrue(method_exists($object, $method), get_class($object).' object has '.$method.' method');
    }
}

class ModelTestCase extends BaseTestCase {
    function testClass() {
        $this->assertTrue(class_exists('Guestbook'), 'Guestbook class exists');
    }
    
    function testSetDb() {
        $model = new Guestbook;
        $this->assertMethodExists($model, 'setDb');
    }
    
    function testGetComments() {
        $model = new Guestbook;
        $this->assertMethodExists($model, 'getComments');
        
        $this->assertIsA($comments = $model->getComments(), 'array'
            , 'Guestbook::getComments() returns an array [%s]');
        $this->assertTrue(count($comments)>1, 'returned more than one comment');
        $this->assertGreaterThan(
            $comments[0]['created']
            ,$comments[1]['created']
            );
            
    }
    
    function testGetCommentQueryDatabaseWithOrderByClause() {
        $model = new Guestbook;
        $db = new Mockadodb_mysql;
        $db->expectOnce('getArray', array(new WantedPatternExpectation('/order\s+by\screated\s+desc\s*$/i')));
        $model->setDb($db);
        $model->getComments();
    }
    
    function assertGreaterThan($val1, $val2) {
        $this->assertTrue($val1 > $val2, $val1.' is greater than '.$val2);
    }
}

class DbTestCase extends BaseTestCase {
    function testDbConnection() {
        $this->assertIsA($db = DB::conn(), 'adodb_mysql');
        $this->assertMethodExists($db, 'getArray');
    }
    function testAccessToGuestbookTable() {
        $db = DB::conn();
        $this->assertIsA($rs = $db->getArray('select * from Guestbook'), 'array');
        $this->assertTrue(count($rs) > 1, 'result contains more than one row');
        $expected_keys = array('name','comment','created');
        foreach(array_keys($rs[0]) as $key) {
            $this->assertTrue(in_array($key,$expected_keys), $key.' was not expected');
        }
    }
}


class PageTestCase extends WebTestCase {
    protected $url='http://gentoo/~sweatje/conf/phpt_tdd/live/guestbook.php';
    function testPage() {
        $this->get($this->url);
        $this->assertNoUnwantedPattern('/fatal error/i');
        $this->assertTitle('Live Guestbook');
        $this->assertWantedPattern(
            '~
            (<h2>\s*.*?</h2>.*?\w+.*?.*){2,}
            ~imsx');
    }
    function testInputForm() {
        $this->get($this->url);
        $this->assertFieldByName('name');
        $this->assertFieldByName('comment');
    }
}


With these test cases, we developed:

db.php:


?>
<?php

require_once 'adodb/adodb.inc.php';

class DB {
        //static class, we do not need a constructor
        private function __construct() {}

        public static function conn() {
                static $conn;
                if (!$conn) {
                        $conn = adoNewConnection('mysql');
                        $conn->connect('localhost', 'phpauser', 'phpapass', 'phpa');
                        $conn->setFetchMode(ADODB_FETCH_ASSOC);
                }
                return $conn;
        }
}

?>

model.php

<?php

/**

getComments()  sorted by most recent first
listGuestNames()
addComment() - no blank comment, no SPAM

*/

require_once 'db.php';

class Guestbook {
    protected $db;
    function __construct() {
        $this->db = DB::conn();
    }
    function setDb($db) {
        $this->db = $db;
    }
    function getComments() {
        return $this->db->getArray(
            'select 
                * 
            from Guestbook 
            order by created desc');
    }
}

?>

guestbook.php

<?php

<html>
<head>
<title>Live Guestbook</title>
</head>
<body>
<h1>All my comments</h1>
< ?php

require_once 'model.php';

$guestbook = new Guestbook;
foreach($guestbook->getComments() as $comment) {
    echo '<h2>', $comment['name'], '</h2>', $comment['comment'], '';
}

?>
<h1>Add your comment</h1>
<form method="post">
<input type="text" name="name"/>
<textarea name="comment"></textarea>
</form>
</body>
</html>

?>