testik, the little unit tester

02 April 2008 // php. things.

Unit testing and TDD are great and so is SimpleTest, however there's a problem: you have to write code for tests, and sometimes quite a lot. And this code is often not really readable and self-documented. testik is an attempt to make testing as simple as it could be.

 

Suppose you're writing some new code, for example, an innovative factorial calculator, and need a test suite. With testik, it's going to look like this:

factorial.test.php:

// include what we're testing
require 'factorial.php';

// include and run testik
require 'testik.php';
testik();

// tests

factorial(2); # 2
factorial(5); # 120

You run this file (no matter, in browser or from the command line) and... see just nothing. If testik has nothing to complain, it doesn't bother you.

Now, let's add a new test to the suite:

factorial.test.php:

require 'factorial.php';

require 'testik.php';
testik();

factorial(2); # 2
factorial(5); # 120
factorial(3); # 8

If we run this, we'll see something like

test failed in factorial_test.php on line 16
expect: int(8)
actual: int(6)

You got the idea: testik looks for the lines in php source containing the '#' symbol and compares what's on the left of '#' (= actual value) to what's on the right (= expected value). Other lines are executed as is, so that your test cases can be arbitrary complex:

$db = DBFactory::connection();
$mapper = new UserMapper($db);
$st = $mapper->select('*')->where('id')->moreThan(10);

$st->count(); # 123 <-- testik 'sees' only this line

Of course, you can also group test cases in functions or class methods, testik doesn't enforce any particular structure, but it's your responsibility to call the test functions when appropriate.

Back to the factorial example, suppose you decided to document your package and provide a small text file with comments and examples.

factorial.doc

Hello!

This is my very own implementation of the factorial function
(http://en.wikipedia.org/wiki/Factorial)

MIT license

factorial($n) computes a factorial of $n

    factorial(4); # 24
    factorial(5); # 120

By definition, 1! = 0! = 1

    factorial(1); # 1
    factorial(0); # 1

Invalid input yields "error"

    factorial(-1);    # "error"
    factorial('foo'); # "error"

Number more than 10 yields "overflow"

    factorial(10);    # 3628800
    factorial(11);    # "overflow"
    factorial(111);   # "overflow"

Hope you like this function!

Although this file is not php, you can still use testik to test the code examples:

factorial.test.php

require 'factorial.php';

require 'testik.php';
testik('factorial.doc', 'text');

The second parameter equal to text tells testik to ignore all lines that start with a non-space and execute other (indented) lines, following the same '#' rule as above. This means essentially that you can directly use your 'readme' files or specifications as test suites and vice versa. Quite neat!

Sometimes there's no need for the separate documentation file and everything is documented in the code, e.g. using phpdoc syntax.

factorial.php

/**
    * Takes an integer and computes the factorial of it
    *
    * @param int
    * @return int | string
    *
    * <code>
    *    factorial(5); # 120
    *    factorial(1); # 1
    *    factorial(0); # 1
    * </code>
    *
    * invalid input yields "error"
    * <code>
    *    factorial(-1);    # "error"
    *    factorial('foo'); # "error"
    * </code>
    *
    * number more than 10 yields "overflow"
    * <code>
    *    factorial(10);    # 3628800
    *    factorial(11);    # "overflow"
    *    factorial(111);   # "overflow"
    * </code>
    *
**/
function factorial($n) {
...

To test this with testik, we need to modify our test file slightly:

factorial.test.php

require 'factorial.php';

require 'testik.php';
testik('factorial.php', 'doc');

doc means that testik should only execute code within <code>...</code> tags.

Ok, that's pretty much all about that. Of course, testik is a very simple facility, it doesn't contain fancy assertion rules and cannot replace SimpleTest'ing of a 100,000 lines codebase. The good news are that the whole 'framework' is only 50 lines long.

<?php
function testik($file = null, $mode = 'php') {
    $args = func_get_args();
    if(count($args) < 4) {
        $d = debug_backtrace(); $f = realpath($d[0]['file']);
        $file = is_null($file) ?  $f : realpath($file);
        $line = ($file == $f) ? $d[0]['line'] : 0;
        $text = implode("\n",
            ($line > 0 ? array_fill(0, $line, '#') : array()) +
            preg_split("~\r?\n~", file_get_contents($file)));
        switch($mode) {
            case 'php':
                if($file == $f)
                    $text = preg_replace('~\b(function)[ \t]+([a-zA-Z])~',
                        '$1 _DISABLE_$2', $text);
                break;
            case 'text':
                $text = preg_replace('~^\S.*~m', '//', $text);
                break;
            case 'doc':
                $text = explode("\n",
                    str_replace(array('@code', '@endcode'),
                    array('<code>', '</code>'), $text));
                $text = preg_replace('~^[ \t]*\*(?!/)~m', '', $text);
                $t = array_fill(0, count($text), '//');
                $c = 0;
                foreach($text as $n => $s)
                    if(!$c && strpos($s, "<code>") !== false) $c = 1;
                        else if($c && strpos($s, "</code>") !== false) $c = 0;
                            else if($c) $t[$n] = $s;
                $text = implode("\n", $t);
                break;
        }
        $text = preg_replace('~^[ \t]*echo (.+);[ \t]*#~m', 'strval($1); #', $text);
        $text = preg_replace('~^(.+);[ \t]*#[ \t]*(.+)$~m',
            __FUNCTION__ . "($1,$2,'$file',__LINE__);", $text);
        if(preg_match("~^\s*<\?~", $text)) $text = "?>$text";
        #foreach(explode("\n", $text) as $k=>$v) printf("<pre>%04d: %s</pre>\n", $k+1, $v);
        eval($text);
        if($file == $f) exit;
    } else if($args[0] != $args[1]) {
        ob_start();
        echo "test failed in $args[2] on line $args[3]\n";
        echo "expect: "; var_dump($args[1]);
        echo "actual: "; var_dump($args[0]);
        $s = ob_get_clean();
        (php_sapi_name() == 'cli') ? fwrite(STDERR, $s) :
            print("<pre>" . htmlspecialchars($s) . "</pre>");
    }
}
?>

download

 
If you think this comment is spam or otherwise completely irrelevant here, feel free to hide it. The comment disappears immediately, though it is not deleted, so I have an option to "unhide" it later.
 

comment on this