First things first: Luis, Brad, everyone at Ortus Solutions: I bloody love you guys. Working with TestBox and MockBox is an absolute pleasure compared to getting their PHP equivalents installed and working properly.
Over the last coupla weeks, meself and Duncan have been investigating unit testing on PHP, and specifically how to mock dependencies. On our old CFML code base, we have 1000s of test, and we use MockBox heavily, as we have rather a lot of dependencies which interfere with testing if they're not mocked. We only ever really moved to TestBox as a proof-of-concept before our CFML codebase was put into maintenance mode, and we shifted to PHP. Prior to that we had been using MXUnit for a number of years.
PHPUnit is a bit rough-and-ready in comparison to TestBox, but it's solid and will do the trick. However we both had an arsehole of a time getting AspectMock to work. And we both failed and moved on to other things. Earlier I'd looked at Mockery as a solution for mocking - its docs are better than AspectMock's (both a recommendation for Mockery, and an indictment of AspectMock), but it did not do something things we expect to need to be doing, whereas AspectMock claims it can (mocking static methods).
Anyway, this afternoon I found myself furrowing my brow and thought "I'm going to get that bloody thing to work... or else". Fortunately after a lot of swearing and wondering if I can hunt down and punish AspectMock's author... I finally noticed what I had been doing wrong. I presume Duncan was doing the same thing wrong, but don't know. Anyway, I got it working.
So here's my AspectMock mission from this afternoon.
Installation
Composer
Before installing AspectMock, I needed to install Composer, which seems to be PHP's package manager. This was really easy: download the binary, run it. It did hiccup once as I didn't have the SSL extension enabled in my PHP install, but that was easy to fix: just uncomment it from php.ini.AspectMock
Installing stuff with Composer is a breeze. In my app directory I created a composer.json file:{
"require-dev": {
"codeception/aspect-mock": "*"
}
}
Then run Composer in that directory, and it chugs away and installs whatever it's been told to do:
c:\php\www\experiment\phpunit>composer update
Loading composer repositories with package information
Updating dependencies (including require-dev)
- Installing doctrine/lexer (v1.0)
Loading from cache
- Installing doctrine/annotations (v1.2.1)
Loading from cache
- Installing jakubledl/dissect (v1.0.1)
Loading from cache
- Installing andrewsville/php-token-reflection (1.4.0)
Loading from cache
- Installing lisachenko/go-aop-php (0.5.0)
Loading from cache
- Installing codeception/aspect-mock (0.5.1)
Loading from cache
jakubledl/dissect suggests installing symfony/console (for the command-line tool
)
lisachenko/go-aop-php suggests installing symfony/console (for the command-line
tool)
Writing lock file
Generating autoload files
c:\php\www\experiment\phpunit>
Loading composer repositories with package information
Updating dependencies (including require-dev)
- Installing doctrine/lexer (v1.0)
Loading from cache
- Installing doctrine/annotations (v1.2.1)
Loading from cache
- Installing jakubledl/dissect (v1.0.1)
Loading from cache
- Installing andrewsville/php-token-reflection (1.4.0)
Loading from cache
- Installing lisachenko/go-aop-php (0.5.0)
Loading from cache
- Installing codeception/aspect-mock (0.5.1)
Loading from cache
jakubledl/dissect suggests installing symfony/console (for the command-line tool
)
lisachenko/go-aop-php suggests installing symfony/console (for the command-line
tool)
Writing lock file
Generating autoload files
c:\php\www\experiment\phpunit>
I fell into a bit of a trap here as I saw the bit that says "[whatever] suggests installing [other shite]", and the completist in my went "hmmm... OK". And updated my composer.json file and installed those too. Then those ones has more suggestions of their own. "OK, I'll play yer silly game," thinks Adam, and added those ones in too. And, of course, having installed those, those ones came back with more bloody suggestions... and... well after about four goes of installing more and more shite I went "screw this", and reverted my composer.json and went back to just installing AspectMock. The good thing is Composer removed all the stuff I changed my mind about too.
I figured if I actually needed any of that other shite, I'd install it as/when.
Autoload
Next I had to monkey with my autoloader so AspectMock etc would load. This is done in two parts now (you might recall me going overautoload.php
files in earlier articles: "Looking at PHP's OOP from a CFMLer's perspective: classes, objects, properties"), the AspectMock autoload, which calls the Composer autoload, and my own autoloading stuff. Here's the AspectMock one:<?php
// aspectMockAutoload.php
include __DIR__.'/../vendor/autoload.php'; // composer autoload
$kernel = \AspectMock\Kernel::getInstance();
$kernel->init([
'debug' => true,
'includePaths' => [__DIR__]
]);
$kernel->loadFile('autoload.php');
And note how it calls in my own autoload.php at the end there (it's the same as I always use, so I won't repeat it here).
This all seemed to load fine (it didn't error, and
$kernel
seemed to have AspectMock stuff in it afterwards.Test: fail
There some sample code in the AspectMockreadme.md
file which I thought was as good a place to start as any: "Allows replacement of class methods."However the code is full of typos (it doesn't even get past the parser, let alone run), and simply doesn't work. Not in an AspectMock sense (although it didn't work for me in that way either, to start with), but in general. Here's their code sample:
<?php
function testUserCreate()
{
$user = test::double('User', ['save' => null]));
$service = new UserService;
$service->createUserByName('davert');
$this->assertEquals('davert', $user->getName());
$user->verifyInvoked('save');
}
The issue here is
$user
is not a User object, it's a class proxy. Which is fine, and what we want, but it means that we can't call methods on it, like getName()
. Because it's not an actual user object. And certainly not the one hidden away in UserService:<?php
class UserService {
function createUserByName($name)
$user = new User;
$user->setName($name);
$user->save();
}
}
So that's not really encouraging: sample code should work. However I had a breeze through the AspectMock source code, and other than the typos and that one brain fart, the code should work. But it didn't. No mocking was happening. Here's my version on that code:
<?php
// User.php
class User {
private $name;
function __construct(){
echo "User->__construct() called\n";
}
function setName($name){
echo "User->setName() called\n";
$this->name = $name;
}
function getName(){
echo "User->getName() called\n";
return $this->name;
}
function save(){
echo "User->save() called (WE DO NOT WANT TO SEE THIS)\n";
}
}
<?php
// UserService.php
class UserService {
function createUserByName($name) {
echo "UserService->createUserByName() called\n";
$user = new User();
$user->setName($name);
$user->save();
return $user;
}
}
<?php
// UserServiceTest.php
use AspectMock\Test as test;
class UserTest extends \PHPUnit_Framework_TestCase
{
protected function tearDown()
{
test::clean(); // remove all registered test doubles
}
function testUserCreate()
{
$userProxy = test::double('User', ['save' => function(){
echo "MOCKED User->save() called\n";
}]);
$service = new UserService;
$user = $service->createUserByName('Zachary');
$this->assertEquals('Zachary', $user->getName());
$userProxy->verifyInvoked('save');
}
}
The key thing here is that on each method, I'm outputting some telemetry so as to watch what's getting called. Basically UserService creates a User, and calls
save()
on it (which is completely wrong architecture IMO, but so be it). We have mocked-out the save()
call in this test. The theory being that save()
would be hitting the DB or something, and we don't want that to happen in our tests.OK. So now I run it:
C:\php\www\experiment\phpunit\aspectMock>phpunit --bootstrap aspectMock
Autoload.php test\UserServiceTest.php
PHPUnit 3.7.22 by Sebastian Bergmann.
Configuration read from C:\php\www\experiment\phpunit\aspectMock\phpunit.xml
FUserService->createUserByName() called
User->__construct() called
User->setName() called
User->save() called (WE DO NOT WANT TO SEE THIS)
User->getName() called
Time: 0 seconds, Memory: 3.50Mb
There was 1 failure:
1) UserTest::testUserCreate
Expected User->save to be invoked but it never occur.
C:\php\www\experiment\phpunit\vendor\codeception\aspect-mock\src\AspectMock\Proxy\Verifier.php:64
C:\php\www\experiment\phpunit\aspectMock\test\UserServiceTest.php:22
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
C:\php\www\experiment\phpunit\aspectMock>
Autoload.php test\UserServiceTest.php
PHPUnit 3.7.22 by Sebastian Bergmann.
Configuration read from C:\php\www\experiment\phpunit\aspectMock\phpunit.xml
FUserService->createUserByName() called
User->__construct() called
User->setName() called
User->save() called (WE DO NOT WANT TO SEE THIS)
User->getName() called
Time: 0 seconds, Memory: 3.50Mb
There was 1 failure:
1) UserTest::testUserCreate
Expected User->save to be invoked but it never occur.
C:\php\www\experiment\phpunit\vendor\codeception\aspect-mock\src\AspectMock\Proxy\Verifier.php:64
C:\php\www\experiment\phpunit\aspectMock\test\UserServiceTest.php:22
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
C:\php\www\experiment\phpunit\aspectMock>
Dammit.
And this is where I'd got too all the time I was trying to get this bloody thing to work. I had actually concluded AspectMock didn't actually work. Usually I try to blame me when things don't work, but I'd spent a coupla days on this, and similarly Duncan, and we had got to the same point. And I haven't found much usage of AspectMock out there, so could find no proof of people using it.
But this afternoon I read the docs more closely.
Me: fail
I found this in the "Customization" section of the readme.md file:Their emphasis.includePaths
directories with files that should be enhanced by Go Aop. Should point to your applications source files as well as framework files and any libraries you use..
excludePaths a paths in which PHP files should not be affected by aspects. You should exclude your tests files from interception.
And just down from that (and this is all in bold):
It's pretty important to configure AspectMock properly. Otherwise it may not work as expected or you get side effects. Please make sure you included all files that you need to mock, but your test files as well as testing frameworks are excluded.
You know what I had been doing? As I was just messing around, I had all my files in the same dir. TBH, why the hell shouldn't I? AspectMock should be leaving everything the hell along, except for what I tell it to monkey with. But seemingly no. Hmmm... this is OK though, as it will be how our files are organised anyhow. I just wish it hadn't mattered. Or that I noticed this a few days ago.
Test: pass
So I reorganised my code so that theUser.php
and UserService.php
files where in a /src
subdirectory, and UserServiceTest.php
was in a /test
directory, adjusted my autoload.php
file and pointed my includePaths
to the right place, and:
C:\php\www\experiment\phpunit\aspectMock>phpunit --bootstrap aspectMock
Autoload.php test\UserServiceTest.php
PHPUnit 3.7.22 by Sebastian Bergmann.
Configuration read from C:\php\www\experiment\phpunit\aspectMock\phpunit.xml
.UserService->createUserByName() called
User->__construct() called
User->setName() called
MOCKED User->save() called
User->getName() called
Time: 0 seconds, Memory: 3.50Mb
OK (1 test, 1 assertion)
C:\php\www\experiment\phpunit\aspectMock>
Autoload.php test\UserServiceTest.php
PHPUnit 3.7.22 by Sebastian Bergmann.
Configuration read from C:\php\www\experiment\phpunit\aspectMock\phpunit.xml
.UserService->createUserByName() called
User->__construct() called
User->setName() called
MOCKED User->save() called
User->getName() called
Time: 0 seconds, Memory: 3.50Mb
OK (1 test, 1 assertion)
C:\php\www\experiment\phpunit\aspectMock>
Yay! It's calling the mocked method!
That's enough of that
And now it's 8pm (I started this all at 4pm), and I want dinner and I've half a bottle of wine staring at me going "well I'm not gonna drink myself?!". And I'm going to Germany tomorrow (CFCamp), so I had better... um... think about deciding what to take with me (I'll not do anything about this until 30min before I need to go to the airport, but I should at least think about it a bit ;-)Righto.
--
Adam