testik, the little unit tester
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>");
}
}
?>
comment on this