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.

PHP Form Builder Class

Project Overview

The PFBC (PHP Form Builder Class) project is developed with the following goals in mind...

  • Promote rapid development of forms through an object-oriented PHP structure.
  • Eliminate the grunt/repetitive work of writing the html and validation when building forms.
  • Reduce human error by using a consistent/tested utility.

This project was first release to the open source community on April, 24 2009 at PHPClass.org. It was moved to its current location at Google's Project Hosting service on November 16, 2009. Since the initial release, the project has gone through over 20 revisions and is still under active development.

Version 2.x represents the first major rewrite in the project's history and attempts to evolve PFBC into a code base that is more efficient, easier to manage, and extensible.

System Requirements

  • PHP 5

What's New/Different in Version 2.x

PFBC version 2.x is a complete rewrite of the last 1.x version released - 1.1.4. This rewrite was done for several reasons...

  • Organization - In version 1.x, most of the project's functionality was stuffed inside a single file - class.form.php, which made code's readability and management a real challenge. Version 2.x organizes the various pieces of the project into a logical directory structure.
  • Extensibility - The new object-oriented model in version 2.x allows you extend the project's functionality more easily. Examples of extensibility include creating your own form elements, customizing the form's html/css structure, and/or creating new validation rules.
  • Performance - In order to comply with xhtml strict rules, version 1.x used ajax to insert the form's required css/javascript into the document's head tag, negatively impacted performance. Version 2.x implements a new, simplified strategy where the form's required css is included before the form's html structure is sent to the browser (javascript is inserted after the form's html structure). This improves performance and reduces the occurance of unexpected shifts caused when the form's css is loaded and applied after the form's mark up is visible on the webpage.

PFBC version 2.x is not a direct replacement for the last 1.x version released - 1.1.4. In other words, migrating from 1.x to 2.x requires more than simply overwriting the project's directory on your web server. Below is a list of changes that you should be aware of when transitioning to using PFBC version 2.x.

php-form-builder-class

Project Overview

The PFBC (PHP Form Builder Class) project is developed with the following goals in mind...

  • Promote rapid development of forms through an object-oriented PHP structure.
  • Eliminate the grunt/repetitive work of writing the html and validation when building forms.
  • Reduce human error by using a consistent/tested utility.

This project was first release to the open source community on April, 24 2009 at PHPClass.org. It was moved to its current location at Google's Project Hosting service on November 16, 2009. Since the initial release, the project has gone through over 20 version releases and is still under active development.

Version 2.x represents the first major rewrite in the project's history and attempts to evolve PFBC into a code base that is more efficient, easier to manage, and extensible.

System Requirements

Installation Instructions

Before writing any code, you'll first need to download the latest version of PFBC, extract the contents zip file, and upload the PFBC directory within the document root of your web server. NOTE: The phrase "within the document root" means that there must be a public path to the PFBC directory (e.g. http://www.mydomain.com/PFBC). The other files/directories outside of the PFBC folder that are included in the download are provided only for instruction and can be ommitted from your production environment. Once the PFBC directory is up on your web server, you're ready to get started creating your first form.

Examples/Tutorials

The links provided below are meant to demonstrate the key features included in the project. Currently, these links are using the pfbc2.0-php5 release. Each of these examples are also included in both of the project's download files seen above.

Code Samples

 <?php
//PFBC 2.x PHP 5 >= 5.3
session_start
();
include
($_SERVER["DOCUMENT_ROOT"] . "/PFBC/Form.php");
$form
= new PFBC\Form("GettingStarted", 300);
$form
->addElement(new PFBC\Element\Textbox("My Textbox:", "MyTextbox"));
$form
->addElement(new PFBC\Element\Select("My Select:", "MySelect", array(
   
"Option #1",
   
"Option #2",
   
"Option #3"
)));
$form
->addElement(new PFBC\Element\Button);
$form
->render();

//PFBC 2.x PHP 5
session_start
();
include
($_SERVER["DOCUMENT_ROOT"] . "/PFBC/Form.php");
$form
= new Form("GettingStarted", 300);
$form
->addElement(new Element_Textbox("My Textbox:", "MyTextbox"));
$form
->addElement(new Element_Select("My Select:", "MySelect", array(
   
"Option #1",
   
"Option #2",
   
"Option #3"
)));
$form
->addElement(new Element_Button);
$form
->render();
?>