Wednesday, February 22, 2012

Unit Testing Using Generated Test Cases

This is a follow-up post to my super simple php unit test base class that I wrote in a bind for Facebook's HackerCup. In that post I provided a real test I created using my base class jphpunit which included a test using the problems sample input and output and another test that used a generator. Today I will share the generator I used to help me test my code against the full set of constraints of the problem. This or a similar pattern can be used in your tests, in fact I highly recommend PHPUnit.

I created an abstract class for my test case generator so that I can use the same test pattern for each of the Facebook problems. <?php /** * Generates input and output data for use in unit tests. */ abstract class fbbase_base_generator { private $answer = ''; private $test_cases; public function __construct($numTests = 20, $options = array()) { // Generate test cases for ($i = 1; $i <= $numTests; $i++) { $this->test_cases[$i] = $this->_createTestCase($options); } date_default_timezone_set('America/Los_Angeles'); } public function saveFile() { $filename = 'data/generated_'. date('Y-m-d_H-i-s') .'.txt'; $input = count($this->test_cases) ."\n"; foreach ($this->test_cases as $i => $test_case) { $input .= $test_case['input'] ."\n"; } file_put_contents($filename, $input); return $filename; } public function getCorrectAnswer() { if (!empty($this->answer)) { return $this->answer; } $answer = ''; foreach ($this->test_cases as $i => $test_case) { $answer .= 'Case #'. $i .': '. $test_case['output'] ."\n"; } $this->answer = trim($answer); return $this->answer; } /** * @param array Any options that you want your test * certain conditions are passed directly to this * function from the constructor. * @return array An array of the test case, should * have 'input' and 'output' key indexes. Input is * the line that will go in the input file, output * is the expected output after the "Case #{$i}:", * do not include leading or trailing whitespace * unless it is expected as part of the output, as * they will be added for you. */ abstract protected function _createTestCase($options); } This base class allows me to define the _createTestCase() method with the details of the problem at hand without having to worry about re-writing the code that will remain the same no matter what the problem is.

Here is an example of the class in use. <?php /** * Generates input and output data for use in unit tests. */ require_once('fbbase_base_generator.php'); class alphabet_soup_generator extends fbbase_base_generator { protected function _createTestCase($options) { $alphabet = 'BDFGIJLMNOQRSTVWXYZ'; // A partial alphabet so we don't // accidentally create more instances of HACKERCUP $soup = array(); // An array of all letters in our soup // Add letters for HACKERCUP $hacker_cups = rand(0,30); for ($i = 0; $i < $hacker_cups; $i++) { $soup[] = 'H'; $soup[] = 'A'; $soup[] = 'C'; $soup[] = 'K'; $soup[] = 'E'; $soup[] = 'R'; $soup[] = 'C'; $soup[] = 'U'; $soup[] = 'P'; } // Choose a partial set of our HACKERCUP letters to include $set = rand(0,5); $num_sets = rand(1,10); for ($i = 0; $i < $num_sets; $i++) { // Add letters from our partial alphabet $soup = array_merge($soup, str_split($alphabet)); // Add our partial set of HACKERCUP letters switch($set) { case 0: $soup[] = 'H'; $soup[] = 'A'; $soup[] = 'C'; $soup[] = 'K'; $soup[] = 'E'; $soup[] = 'R'; break; case 1: $soup[] = 'C'; $soup[] = 'U'; $soup[] = 'P'; break; case 2: $soup[] = 'H'; $soup[] = 'A'; $soup[] = 'C'; $soup[] = 'C'; $soup[] = 'U'; $soup[] = 'P'; break; case 3: $soup[] = 'K'; $soup[] = 'E'; $soup[] = 'R'; $soup[] = 'U'; $soup[] = 'P'; break; case 4: $soup[] = 'H'; $soup[] = 'C'; $soup[] = 'R'; $soup[] = 'C'; $soup[] = 'U'; break; case 5: $soup[] = 'H'; $soup[] = 'A'; $soup[] = 'C'; $soup[] = 'K'; $soup[] = 'R'; $soup[] = 'U'; $soup[] = 'P'; break; default: $soup[] = 'H'; $soup[] = 'A'; $soup[] = 'R'; $soup[] = 'K'; $soup[] = 'E'; $soup[] = 'P'; } } // Jumble our soup and then implode it to words shuffle($soup); $input = ''; foreach ($soup as $letter) { $input .= $letter; // Randomly add a space, approximately every 8 characters if (rand(0, 8) === 0) { $input .= ' '; } } return array('input' => $input, 'output' => $hacker_cups); } } In this case the problem was that you are given a list of phrases that are written in alphabet soup, and need to figure out how many times you can write HACKERCUP using only the letters from that phrase. Since it doesn't matter what the phrase is (or that is made up of real words), the test cases generated are kept simple. You also want to make sure you add enough complexity into the test cases to ensure your code meets some of the edge cases. For instance, I added extra characters from the word HACKERCUP, but was careful not to inadverently generate a test case that would have more instances then would be expected.

Finally, here is how the generator can be used in a unit test. I'm using my jphpunit class, but it should work as-is in a PHPUnit test as well. <?php // Inside your unit test public function testGenerated() { $gen = new alphabet_soup_generator(); $fixture = $gen->getCorrectAnswer(); $input = $gen->saveFile(); $output = array(); exec('../alphabet_soup.php '. $input, $output); $this->assertEquals($fixture, implode("\n", $output)); }

No comments: