When you're writing PHPUnit tests, sometimes you want the thing you're testing to be able to work on a provided ancillary object—basically a dependency injection—without having to set up the whole of that ancillary object. For example, in my case I wanted to test a model layer that, as part of creating entities, received a Symfony UploadedFile
object usually populated by a browser form submission. Faking the entire process of form submission, merely in order to satisfy UploadedFile
, is a lot of overhead: but there's a better way!
Situations like this are usually tackled using mock objects—which are mostly empty shells that duplicate all the real object's callbacks, each of which either do nothing or defer to the object's original callback—so in my test I summoned a mock based on UploadedFile
:
<?php $mockBuilder = $this->getMockBuilder( // Class name, using namespaces. 'Symfony\Component\HttpFoundation\File\UploadedFile' ); $mockBuilder->disableOriginalConstructor();
You can call ::getMock()
directly instead, but I also needed to disable the original UploadedFile::__construct()
, and doing that through getMock can be a bit arcane. As I will one day have to hand this code over to someone else, it's much clearer to do that this way.
We now have our mock builder, but what about when the mock is passed into the object under test, which tries to use it; what needs to happen then? The tested object will want to call UploadedFile::move($targetDir, $targetFilename)
, to move the submitted data to a known file location. But our mock object will literally do notthing when its equivalent callback is made.
There are a lot of examples out there for trivial mocking-up of callback responses: these usually focus on the return values; maybe the mock callback needs to return a number 2, then a number zero, then a NULL; maybe the callback should object if it's called more than three times. This assumes the callback rightfully should have no side effects: unfortunately, almost the whole point of ::move()
in our example is that it has a side effect of creating a new file!
In theory, I could take a (big) step backwards at this point and mock up an entire filesystem, never really writing files to disk; in practice, I was already set up to redirect my model layer's behaviour and write files in a temporary location.
All I wanted to do was replace the mock callback with an actual callback, that created an empty file on disk. Here's how I next modified the mock to do that:
<?php // Tell the mock builder we'll be overriding ::move(). $mockBuilder->setMethods(array("move")); // Generate a mock object. $mockUploadedFile = $mockBuilder->getMock(); // Add a method for a single call to ->move($targetDir, $targetFilename). $mockUploadedFile->expects($this->once())->method("move") ->will($this->returnCallback(array($this, 'mockMove')));
The final line is how to pass in as a callback, not a simple function, but an actual method of my test: that way, I could keep everything encapsulated within it. As you can see I've also told the mock how often it should expect this method to be called—::once()
—which might not be strictly necessary but is good practice. I can now define a public method MyTest::mockMove($targetDir, $targetFilename)
that does what I want!
Here's the resulting, full PHPUnit test:
<?php /** * @class * Test: MyApp\Models\Dataset. */ class MyTest extends \PHPUnit_Framework_TestCase { // To be replaced with new Dataset() during setUpBeforeClass. public static $model = NULL; // Directory for datasets: we keep track of that too. private static $dir = NULL; /** * Set up before all tests: create dataset folder and Dataset() handler class. */ public static function setUpBeforeClass() { self::$dir = getenv('DIR_DATASETS'); mkdir(self::$dir); self::$model = new Dataset(); } /** * Test: MyApp\Models\Dataset::create(). */ public function testCreate() { // Mock up an UploadedFile, disabling its constructor. $mockBuilder = $this->getMockBuilder( 'Symfony\Component\HttpFoundation\File\UploadedFile' ); $mockBuilder->disableOriginalConstructor(); // Tell the mock builder we'll be overriding ::move(). $mockBuilder->setMethods(array("move")); // Generate a mock object. $mockUploadedFile = $mockBuilder->getMock(); // Add a method for a single call to ->move($targetDir, $targetFilename). $mockUploadedFile->expects($this->once())->method("move") ->will($this->returnCallback(array($this, 'mockMove'))); // Now create our dataset object to test and make test assertions. $dataset = self::$model->create($mockUploadedFile); /* Test assertions here. */ } /** * Helper: provide the UploadedFile mock with a ::move() method. * * This needs to touch a temporary file during the dataset creation. */ public function mockMove($dir, $file) { touch("$dir/$file"); } }
I've included a class function to create the temporary folder and initialize the object (model layer) I want to test; and at the end you see my mock callback, MyTest::mockMove()
, which gets called in place of UploadedFile::move()
and just creates an empty file using touch()
.
Mocking up not just objects but their callbacks is straightforward once you know how, but also slightly subtle and very powerful!