Saturday, July 04, 2009

Code introspection and how to fool PHPUnit

PHPUnit is a wonderful tool, but this time it was being a bit too smart. Here's a workaround to fix its mocking capabilities.
When it comes to write unit tests for a php project, PHPUnit is the choice. And since I practice TDD, that moment is before writing any other code.
PHPUnit is much more than a clone of JUnit, and it take advantage of the dynamic aspects of the php language to do amazing things. It even saves and restores global variables to test legacy code. However, I came to an issue, ironically involving cloning: not of a testing framework, but of Plain Old Php Objects. I know, also POPO is a cloned word; but some aspects of static typed languages are very useful even in the php context where you call $this->$method with no hesitation.

PHPUnit sometimes is too smart
Before talking about the issue and the workaround that I produced, let's learn some background: PHPUnit integrates a simple mocking framework, that with 3-4 lines of code let you generate a mock objects to inject in the system under test. This is called test in isolation, and it's the point of the whole unit test strategy: insulate an element from his dependencies and strictly testing his conformance to a contract: it accepts some parameters and it calls some methods.
So I was setting up a Doctrine_Query object, that represents a sql query, and pass it to my Repository (object under test) to have it filled with where conditions. The example is not important, but I was putting it in a mocked QueryCreator factory that sits in the repository. The ParametersMapper was responsible for putting conditions in the query and was mocked as well. What is under test is the behavior of Repository, that pulls a query, has it filled with wheres and executes it, returning a Doctrine_Collection.
I discovered that PHPUnit mock implementation clones the parameters that have to be passed to a mocked object. This make sense, as the assertions on the parameters passed are executed in the teardown, when the test is finished (and it can't be otherwise for constraint like 'this method is called once or more times'). The framework is only trying to be helpful, as cloning parameters insulate their registered copy in the mocked from the object that goes around in the testcase and can have its state modified from when it was passed to the mock:
$parametersProcessor = $this->getMock('Otk_Model_Repository_ParametersProcessor');
$parametersProcessor->expects($this->any())
->method('injectParams')
->with($this->identicalTo($query), $this->equalTo($params))
->will($this->returnCallback(array($this, 'mockQuery')));

When my repository calls $parametersProcessor->injectParams($query, ...) internally, $query is cloned and passed to the callback defined:
public function mockQuery(Doctrine_Query $q, array $params)
{
$this->assertEquals(array('Stub_Book b'), $q->getDqlPart('from'));
$q->where('b.title = ?', 'The Bible');
}

And instead of having a query with the title The Bible condition, my repository still have original one. And obviously, the assertion on the number of object it retrieves fails.

What can we do to bypass cloning?
Looking in the PHPUnit_Framework_MockObject_Invocation::cloneObject() method, that is used to duplicate the parameters for mocked methods to conserve them for evaluation at the test end, I saw that if clone $object throws an exception, the object is not cloned. This still makes sense, because if an object defines a __clone magic method that throws an exception it essentially states "I am not cloneable. Maybe I am a big god object or singleton or other bad stuff that does not want to be duplicated, or the world will end". The method catches the exception and saves the original parameter, considering it still best than nothing.
So I wrote a small subclass of Doctrine_Query for the purpose of this test that will throw an exception if cloned:

class Stub_Doctrine_Query extends Doctrine_Query
{
public function __clone()
{
throw new Exception('PHPUnit, you cannot clone me: I need to be passed by reference so that mockQuery() can modify me.');
}
}

... and watched the test fail anyway. Why? Because Doctrine_Query clones itself when the query is executed for some wacky stuff. I have no idea why, but if it wants, it has to. It still.. makes sense. Why the hell would I want to only pass parameters that can't be cloned? If I needed to clone it in a mocked callback, what would I do?
So I proceed with a little introspection on the call stack, and rewrite the query class as this:

class Stub_Doctrine_Query extends Doctrine_Query
{
public function __clone()
{
$backtrace = debug_backtrace(false);
// $backtrace[0] is current function.
$previousCall = $backtrace[1];
if ($previousCall["function"] == "cloneObject"
&& $previousCall["class"] == "PHPUnit_Framework_MockObject_Invocation") {
throw new Exception('PHPUnit, you cannot clone me: I need to be passed by reference so that mockQuery() can modify me.');
}
}
}

debug_backtrace() is a useful php function that returns the stack of calls made to reach the point in execution where it is called. Php manual explains it better: it is like throwing an exception, catching it immediately and read the stack trace.
What __clone overriding does is essentially "if cloneObject of that class called me, throw an exception so it will stop and use the correct instance".
Before screaming about bad practices of ugly code that behaves differently in testing, please note that in the context where it works differently from the normal (being passed as a parameter to a mock) it was bahaving differently, as it was an object passed by copy (this is really ugly). I am just restoring its normal behavior: if I pass a query to a parameter mapper, I expects it works on the same, identical instance I have (something that === would result true with).

When design an api, a library, or what else, try to helpful and useful, but not too smart. In this case a simple option in the construction of the mocked objects could decide whether to clone parameters, without choosing to always clone.
And that's why fooling PHPUnit. :)

No comments:

Post a Comment