Introducting Diesel - PHP Dependency Injection

PHPUnit made unit testing PHP an actual pleasant experience, but there's still something missing when it comes to generically injecting stubs and mock behavior into your classes when your classes extend beyond simple relationships. For some background on the topic, Martin Fowler's essay on injection is an excellent starting point.

Either you end up with constructors that take endless lists of parameters or with shifty setter methods that can leave your classes-under-test in undesirable states of non-initialization. I.e $class = new Klass(); $class->setSomeReference($mockedObject);

Diesel was born out of the need to avoid both of these situations in a reusable and easily understandable manner. Diesel is pure PHP and does not rely on attributes or XML files. It's not quite a cake pattern -- PHP does not provide mix-in capabilities -- but does provide a similar concept of defining a default implementation for your production environment use cases and respectively (optionally) granularly stubbing out all of your test use cases.

The Diesel system itself is one small PHP class. It works by relying on some static cooperation from each dependent class to implement a method which will register all of its production dependencies. For non-production use cases (i.e. Testing), it relies on each test to configure a non-static Diesel instance with each dependency for the given class under test -- most commonly each dependency will be stubbed using PHPUnit::getMock().

Dependencies for a class may be registered statically or locally, where a local registration is local to the instance upon which it was registered (i.e. no other Diesels will be affected by it). Both registration methods have the same signature, register($owner, $class, $instantiate).  Consumers of Diesel can produce their dependent objects by using its non-static factory method which roughly resembles, create($owner, $class)-- further specified later.

Ok, so what does all of this mean? Let's move into a real world example. One of the utilities built into our build and release tools (Bart) project is a "stop the line" git pre-receive hook. The hook simply queries our development Jenkins server for the status of the latest build. If that build passed, then the commit is permitted, otherwise only commits whose message contains "{buildfix}" may proceed. This is explained in more detail on the project's github home page.  The core class behind this feature relies on two other classes: a Jenkins class and a Git class.

In order to test our Stop-the-Line class, we need to stub out method calls to the Jenkins and Git classes. This is a perfect situation for Diesel to inject stub classes.  So let's see how it works below. For the eager, you may find the entire test class at Stop The Line Test.php.

First, we must configure Stop_The_Line to work with Diesel. That means defining the registration method and accepting a Diesel instance to its constructor. Wait! Didn't I say earlier that taking injection classes as constructor parameters was bad? Well, I've concluded that it's only bad to the extent that they produce unmanageable lists of params. In Diesel's case, all of your injection is controlled by only one parameter. Not a bad compromise. So, Ok, back to the code.

class Stop_The_Line {
  // The constructor param at the end
  public function __construct($git_dir, $conf, Diesel $di = null) {
    // Use the default static dependencies?
    $this->di = $di ?: new Diesel();

    // Use Diesel to produce an instance of a Jenkins Job
    // Notice how Jenkins params are passed in optional 3rd param here
    $this->job = $di->create($this, 'Jenkins_Job', array(
      'host' => $conf['host'],
      'job_name' => $conf['job_name'],
      'w' => $w,
    ));
  }

  // This method will be automatically called by Diesel IF and ONLY IF
  // ...there is no local or static registration for Stop_The_Line
  public static function dieselify($me)
  {
    Diesel::register_global($me, 'Git', function($params) {
      return new Git($params['git_dir']);
    });

    Diesel::register_global($me, 'Jenkins_Job', function($params) {
      return new Jenkins_Job($params['host'], $params['job_name'], $params['w']);
    });
  }
}

Now, our production code can use Stop_The_Line with its default dependencies simply by omitting the last parameter to the constructor.  Test code can inject instances of Jenkins_Job and Git by passing in a so contrived Diesel instance as the last parameter to the constructor.


class Stop_The_Line_Test extends Bart_Base_Test_Case {
  public function testStopTheLine() {
    $job_name = 'the build';
    $conf = array('host' => '...', 'job_name' => $job_name);

    // This is the Diesel ONLY for this test i.e. NO other tests
    // ...will be affected by the dependencies it defines
    $di = new Diesel();

    $this->configureJenkinsJob($di, $job_name, $conf);
    $this->configureGit($di);

    // Our contrived Diesel will be used when STL produces the Jenkins Job
    // ...and it's Git instance
    $stl = new Stop_The_Line('.git', $conf, $di);

    // We expect the line to stop because BOTH checks fail:
    // 1. Jenkins build failed,
    // 2. Commit message did not contain {buildfix}
    $this->assert_throws('Exception', 'Jenkins not healthy', function() use($stl) {
      $stl->verify('hash');
    });

  }  

  private function configureJenkinsJob(Diesel $di, $job_name, $conf) {
    // Use PHPUnit to create a stub job
    $mock_job = $this->getMock('Jenkins_Job', array(), array(), '', false);
  
    // And set it up to say the last build failed
    $mock_job->expects($this->once())
      ->method('is_healthy')
      ->will($this->returnValue(false));

    // Now register this stub job for ONLY this Diesel instance
    $phpu = $this;
    $di->register_local('Git_Hook_Stop_The_Line', 'Jenkins_Job',
      function($params) use($phpu, $conf, $job_name, $mock_job) {
        $phpu->assertEquals($job_name, $params['job_name'],
            'Jenkins job name did not match');

        $phpu->assertEquals($conf['host'], $params['host'],
            'Expected host to match conf');

        return $mock_job;
    });
  }

  private function configureGit(Diesel $di) {
    $mock_git = $this->getMock('Git', array(), array(), '', false);

    // Stop the Line checks if commit message contains {buildfix}
    // Let's see what happens when it doesn't
    $mock_git->expects($this->once())
      ->method('get_commit_msg')
      ->with($this->equalTo('hash'))
      ->will($this->returnValue('The commit message'));

     $di->register_local('Git_Hook_Stop_The_Line', 'Git',
      function($params) use($mock_git) {
       return $mock_git;
     });
  }
}



So as you can see, Diesel lets you granularly control the injection of multiple dependencies per system under test per test. Moreover, it does this in a pure PHP fashion giving programmatic power that just isn't offered (easily or transparently) by XML. In contrast to a annotation based system that injects dependencies via object reflection, Diesel allows you naturally call object constructors and allows those constructors to completely configure themselves in a straightforward manner. A reflection based system requires you to have empty constructors and hides actual implementations from the developer, which can lead to misunderstandings and bugs.

More details can actually be found at the link to the test I provided above. My code above was extracted thence and then tweaked to make it a little simpler (i.e. maybe I made some mistakes). Also, you can see examples how Diesel can be used within a chain of inheritance and for multiple injected classes. In fact, I encourage you to do so as that is where the full utility of Diesel really shines.



Comments

Unknown said…
Very good information thanks for sharing.

Recent posts