Tuesday, September 20, 2011

Unit Testing with PHPUnit in symfony

Today we're going to talk about something I know next to nothing about, but am working on.

Recently I came to the realization that it is time to "Code Up" and learn how to be proficient in building tests, and writing testable code. Testing is a discipline that you will be able to cross code boundaries, something you will be able to carry with you to any language, once you pick up the mindset. More importantly than that, testing allows you to verify your code is working without manually testing or worse, just hoping it works.

So now that we've touched on the why, let's talk about how.

If you're going to do it, do it right!

PHPUnit (by Sebastian Bergmann) is quite literally the gold standard of testing in PHP. It probably helps thatSebastian has taken code metrics in PHP from zero to hero. So it stands to reason that PHPUnit is where you start to look.

A lot of what I write are plugins, because I use things here, there, and everywhere. So one of the most important things to me, was that whatever I used had at least some plugin support. The two plugins that fit what I was looking for closely enough are sfPHPUnitPlugin and sfPHPUnit2Plugin. I chose sfPHPUnit2Plugin simply because I saw it had a github repository, which meant that contributing would be easy, and worst case scenario, keeping my customizations up to date with the core would be a snap.

Note: Just to clarify, both sfPHPUnitPlugin and sfPHPUnit2Plugin work with PHPUnit 3.5.x.

Long story short, I made a pull request on March 11th to add support for plugins holding on to phpunit tests, along with some other goodies. A little while later, it was incorporated! Hurrah! Power to the people. :)

Anyway! For the purpose of this, we're going to assume you're using sfPHPUnit2Plugin, as that's what I use. It's easy. Install the plugin. Run ./symfony phpunit:generate-config, and then run phpunit. If you already have a phpunit.xml file in your root, copy phpunit.xml.dist over it, as it will be updated with the latest path set.

To make your first unit test, it's easiest to just use the plugin's tasks to build them, and work from there. This is a simple deal!

 ./symfony phpunit:generate-unit MyTestName

That will set up the phpunit basics, and put your unit test in test/phpunit/unit/MyTestNameTest.php. See? EASY!

To run your test, either just run 'phpunit', or run 'phpunit test/phpunit/unit/MyTestNameTest.php'. EASY!

Now you know HOW to test, so let's look at WHAT to test. Because you can test everything but if you're not doing it in a way that makes sense, you're just going to get frustrated, and give up. Or worse, you won't realize you're testing the wrong things until disaster strikes.

JMather's Novice Testing Rules

1. Don't test things that are already being tested

Doctrine and Propel both have tons of tests to ensure they're working. You don't have to confirm that when you set something, it gets set, unless you are overriding the setter. You don't have to confirm that when you save, it is added to the database unless you are testing a database connection.

2. Test functionality, not objects

The most important thing to test is your business logic. Not every line deserves a test. Focus on complex business logic. Though, likely, you will end up testing objects if you build them to only contain specific domain logic, but short of that, test what matters. It's ten times better to have 50% code coverage testing all the really important logic than to have 20% code coverage because you're writing massive amounts of silly tests making sure that a + b = c.

3. Just start testing

Your first tests will be bad. No, scratch that, they will be horrible. Why? Because you'll be testing code that wasn't written to be tested. Want an example? Look at this little ditty from majaxDoctrineMediaPlugin. We'll do two clips of the same function, before and after.

 
<?php
// ... snip ...

public function videoToString($path_only = false)
{
$name = $this->getVideoName();
$sha1 = $this->getVideoSha1();
$path = self::sha1ToPath($sha1);
$full_path = sfConfig::get('majax_media_dir').DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.$name;
if (!file_exists(sfConfig::get('majax_media_dir').DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.$name))
{
self::ensurePath($path, sfConfig::get('majax_media_dir'));
$data = $this->getVideoData();
if (majaxMediaToolbox::getFileLock($full_path))
{
file_put_contents($full_path, $data);
majaxMediaToolbox::removeFileLock($full_path);
}
}

if ($this->get('width') !== null || $this->get('height') !== null)
{
$dims = $this->getRatioDimensions($this->get('width'), $this->get('height'), $this->getVideoWidth(), $this->getVideoHeight(), $this->get('aspect_ratio'));
$new_width = $dims[0];
$new_height = $dims[1];
} else {
$new_width = $this->getVideoWidth();
$new_height = $this->getVideoHeight();
}


$name_bits = explode('.', $name);
unset($name_bits[(count($name_bits) - 1)]);
$new_name = implode('.', $name_bits).'.flv';


$args = array('-i', $full_path, '-ar', '22050', '-b', '409600');


switch($this->get('crop_method'))
{
case 'center':
$ratio = min($new_height / $this->getVideoHeight(), $new_width / $this->getVideoWidth());
$height_check = round($this->getVideoHeight() * $ratio);
if ($height_check != $new_height)
{
$diff = (ceil(abs($new_height - $height_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-cropright';
$args[] = $diff_split;
$args[] = '-cropleft';
$args[] = $diff_split;
}
$width_check = round($this->getVideoWidth() * $ratio);
if ($width_check != $new_width)
{
$diff = (ceil(abs($new_width - $width_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-croptop';
$args[] = $diff_split;
$args[] = '-cropbottom';
$args[] = $diff_split;
}
break;

case 'left':
$ratio = min($new_height / $this->getVideoHeight(), $new_width / $this->getVideoWidth());
$height_check = round($this->getVideoHeight() * $ratio);
if ($height_check != $new_height)
{
$diff = (ceil(abs($new_height - $height_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-cropright';
$args[] = $diff;
}
$width_check = round($this->getVideoWidth() * $ratio);
if ($width_check != $new_width)
{
$diff = (ceil(abs($new_width - $width_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-croptop';
$args[] = $diff_split;
$args[] = '-cropbottom';
$args[] = $diff_split;
}
break;

case 'right':
$ratio = min($new_height / $this->getVideoHeight(), $new_width / $this->getVideoWidth());
$height_check = round($this->getVideoHeight() * $ratio);
if ($height_check != $new_height)
{
$diff = (ceil(abs($new_height - $height_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-cropleft';
$args[] = $diff;
}
$width_check = round($this->getVideoWidth() * $ratio);
if ($width_check != $new_width)
{
$diff = (ceil(abs($new_width - $width_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-croptop';
$args[] = $diff_split;
$args[] = '-cropbottom';
$args[] = $diff_split;
}
break;

case 'top':
$ratio = min($new_height / $this->getVideoHeight(), $new_width / $this->getVideoWidth());
$height_check = round($this->getVideoHeight() * $ratio);
if ($height_check != $new_height)
{
$diff = (ceil(abs($new_height - $height_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-cropright';
$args[] = $diff_split;
$args[] = '-cropleft';
$args[] = $diff_split;
}
$width_check = round($this->getVideoWidth() * $ratio);
if ($width_check != $new_width)
{
$diff = (ceil(abs($new_width - $width_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-cropbottom';
$args[] = $diff;
}
break;

case 'bottom':
$ratio = min($new_height / $this->getVideoHeight(), $new_width / $this->getVideoWidth());
$height_check = round($this->getVideoHeight() * $ratio);
if ($height_check != $new_height)
{
$diff = (ceil(abs($new_height - $height_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-cropright';
$args[] = $diff_split;
$args[] = '-cropleft';
$args[] = $diff_split;
}
$width_check = round($this->getVideoWidth() * $ratio);
if ($width_check != $new_width)
{
$diff = (ceil(abs($new_width - $width_check) / 2) * 2);
$diff_split = $diff / 2;
$args[] = '-croptop';
$args[] = $diff;
}
break;

case 'fit':
// fit
$ratio = min($new_height / $this->getVideoHeight(), $new_width / $this->getVideoWidth());
$height_check = round($this->getVideoHeight() * $ratio);
if ($height_check != $new_height)
{
$diff = $new_height - $height_check;
$diff_top = floor($diff / 2);
$diff_bot = $diff - $diff_top;
$new_height = $new_height - abs($diff);
}
$width_check = round($this->getVideoWidth() * $ratio);
if ($width_check != $new_width)
{
$diff = $new_width - $width_check;
$diff_l = floor($diff / 2);
$diff_r = $diff - $diff_l;
$new_width = $new_width - abs($diff);
}
}




$new_width = (ceil($new_width / 2) * 2);
$new_height = (ceil($new_height / 2) * 2);

$new_filename = $new_width.'x'.$new_height;
$new_filename .= '_'.$this->get('crop_method', 'fit').'_'.$new_name;
$new_partial_path = $path.DIRECTORY_SEPARATOR.$new_filename;



// start the transformation code...

$args[] = '-s';
$args[] = $new_width.'x'.$new_height;

$ffmpeg = sfConfig::get('app_majaxMedia_ffmpeg_path', '/usr/bin/ffmpeg');
// now we need to figure out the cropping/padding


$new_full_path = sfConfig::get('majax_media_dir').DIRECTORY_SEPARATOR.$new_partial_path;
$args[] = $new_full_path;


if ($ffmpeg == false || !file_exists($ffmpeg))
{
trigger_error('FFMPEG Not installed. Video source will not be resized', E_WARNING);
$new_partial_path = $path.DIRECTORY_SEPARATOR.$name;
}


if (($ffmpeg != false && file_exists($ffmpeg)) && !file_exists($new_full_path))
{
foreach ($args as $i => $arg)
$args[$i] = escapeshellarg ($arg);

//echo($ffmpeg.' '.join(' ', $args));
$count = 0;
while (majaxMediaToolbox::hasFileLock($full_path) && !majaxMediaToolbox::hasFileLock($new_full_path))
{
usleep(500);
$count++;
if ($count == 10)
break;
}

if (!majaxMediaToolbox::hasFileLock($full_path) && majaxMediaToolbox::getFileLock($new_full_path))
{
exec ($ffmpeg . " " . join (" ", $args));
majaxMediaToolbox::removeFileLock($new_full_path);
}
}


$new_partial_path = '/'.sfConfig::get('majax_media_dir_name').'/'.$new_partial_path;



if ($path_only)
return $new_partial_path;


$render_class = sfConfig::get('app_majaxMedia_video_render', 'majaxMediaVideoRender');
$render = new $render_class();
return $render->render($this, $new_partial_path);
}

// ... snip ...

And then the "work in progress"…

 
<?php
// ... snip ...

public function process(majaxMediaFileInfo $file_info, $new_width = null, $new_height = null, $crop_method = 'fit', $aspect_ratio = '16:9')
{
$name = $file_info->getName();
$sha1 = $file_info->getSha1();
$path = $this->path_builder->render($sha1);
$full_path = sfConfig::get('app_majax_media_cache_dir').DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.$name;
if (!file_exists(sfConfig::get('app_majax_media_cache_dir').DIRECTORY_SEPARATOR.$path.DIRECTORY_SEPARATOR.$name))
{
$this->ensurePath($path, sfConfig::get('app_majax_media_cache_dir'));
$this->file_helper->write($full_path, $file_info->getData());
}

$src_width = $file_info->getVideoWidth();
$src_height = $file_info->getVideoHeight();

if ($new_width !== null || $new_height !== null)
{
list($new_width, $new_height) = $this->getRatioDimensions($src_width, $src_height, $new_width, $new_height, $aspect_ratio);
} else {
list($new_width, $new_height) = array($src_width, $src_height);
}


$args = array('-i', $full_path, '-ar', '22050', '-b', '409600');


$translator_class = sfConfig::get('app_majax_media_video_transformation_builder', 'majaxMediaFFMpegVideoTransformationBuilder');
$translator_fit_class = sfConfig::get('app_majax_media_video_transformation_fit_builder', 'majaxMediaFFMpegVideoTransformationFitBuilder');

    if ($c_m == 'fit')
    {
   $translator = new $translator_fit_class();
   list($new_width, $new_height) = $translator->render($s_w, $s_h, $new_width, $new_height, $c_m);
   } else {
   $translator = new $translator_class();
$new_args = $translator->render($s_w, $s_h, $new_width, $new_height, $c_m);
$args = array_merge($args, $new_args);
}


$new_width = (ceil($new_width / 2) * 2);
$new_height = (ceil($new_height / 2) * 2);

    $new_filename = $this->filename_builder->render($new_width, $new_height, $crop_method, $name, 'flv');

$new_partial_path = $path.DIRECTORY_SEPARATOR.$new_filename;


// start the transformation code...

$args[] = '-s';
$args[] = $new_width.'x'.$new_height;

$ffmpeg = sfConfig::get('app_majax_media_ffmpeg_path', '/usr/bin/ffmpeg');
// now we need to figure out the cropping/padding


$new_full_path = sfConfig::get('app_majax_media_cache_dir').DIRECTORY_SEPARATOR.$new_partial_path;
$args[] = $new_full_path;


if ($ffmpeg == false || !file_exists($ffmpeg))
{
trigger_error('FFMPEG Not installed. Video source will not be resized', E_WARNING);
$new_partial_path = $path.DIRECTORY_SEPARATOR.$name;
}


if (($ffmpeg != false && file_exists($ffmpeg)) && !file_exists($new_full_path))
{
foreach ($args as $i => $arg)
$args[$i] = escapeshellarg ($arg);

//echo($ffmpeg.' '.join(' ', $args));
$count = 0;
while ($this->file_helper->hasFileLock($full_path, false) || $this->file_helper->hasFileLock($new_full_path, false) == false)
{
usleep(500);
$count++;
if ($count == 10)
break;
}

if (!$this->file_helper->hasFileLock($full_path) && $this->file_helper->getFileLock($new_full_path))
{
$this->executor->setExecutable($ffmpeg);
$this->executor->setArguments($args);
$this->executor->execute();
$this->file_helper->removeFileLock($new_full_path);
}
}


$new_partial_path = '/'.sfConfig::get('majax_media_dir_name').'/'.$new_partial_path;



if ($path_only)
return $new_partial_path;


$render_class = sfConfig::get('app_majaxMedia_video_render', 'majaxMediaVideoRender');
$render = new $render_class();
return $render->render($this, $new_partial_path);
}

// ... snip ...

The first thing you will notice is that the new one is ~100 lines, as opposed to ~250. It is still quite long, but it has come a long way. The second is that it has been moved into it's own class. Why? So we can test it! All of the referenced builders (i.e. $this->path_builder, $this->filename_builder and the like) can be swapped out for mock objects with predictable behaviors so we can test this function in near isolation. Isolation is important because it limits your test's culpability for outside interference itself. Over time all of your pieces will be validated by tests, so you can ensure the entire system is "correct."

Take, for example, the majaxMediaFilenameBuilder. I tested it! Why? To make sure I can trust it! It should work as expected. Along the way to ensuring it worked as expected, I realized while the code worked fine, the implementation was hosed.

 
<?php

require_once dirname(__FILE__).'/../../../../../test/phpunit/bootstrap/unit.php';

class unit_majaxMediaFilenameBuilderTest extends sfPHPUnitBaseTestCase
{
  protected $builder = null;

  protected function setUp()
  {
    $this->builder = new majaxMediaFilenameBuilder();
  }

  /**
* @dataProvider testPlainFilenameGenerationProvider
*/
  public function testPlainFilenameGeneration($width, $height, $crop_method, $name, $extension, $result)
  {
    $fn = $this->builder->render($width, $height, $crop_method, $name, $extension);
    $this->assertEquals($fn, $result);
  }

  public function testPlainFilenameGenerationProvider()
  {
    return array(
      array('100', '100', 'center', 'blah.gif', null, '100x100_center_blah.gif'),
      array('29', '320934', 'fit', 'blah.flv', null, '29x320934_fit_blah.flv'),
      array('100', '100', 'center', 'blah.gif', 'jpg', '100x100_center_blah.jpg'),
      array('29', '320934', 'fit', 'blah.mov', 'flv', '29x320934_fit_blah.flv'),
    );
  }
}

Now, why did I make it a separate class? It's just a filename you say? HAH! Fat chance! For many instances, sure, it's just a file name. But what if you wanted to make the filename harder to guess? Well, if that part of the code wasn't replaceable, you would be out of luck. Now you just have to extend majaxMediaFilenameBuilder, override render() to return md5(parent::render(args)).'.'.$extension; and you're golden! Don't you just love OOP?

3. Just keep working at it

1% code coverage becomes 5%, which becomes 10%, 20%, 40%, 80%. Soon enough, you'll find yourself checking your tests to make sure you haven't mucked anything up, and that's when you'll get it. That's when it will really hit home. It found a show-stopping bug you wouldn't have noticed in some other part of the system that was not really related at all to what you were working on. It saved your bacon.

And that's when it gets real.

No comments: