How to write Unit Tests of FuelPHP Application with AspectMock
This post explains how to integrate ApectMock with FuelPHP, and make your FuelPHP applications more testable with replacing static methods with test doubles (mocks).
"Testability" should not be used as argument deciding what design pattern is right to use and what is not.
Requirements:
- FuelPHP 1.7.1 (or 1.8/develop)
- AspectMock master branch cc2be6945a705e65a2a4a12df7e35de82d0129f7 (2013-09-09)
Prepare
Install AspectMock and a required package with Composer.
diff --git a/composer.json b/composer.json
index e1b21ea..006ac5b 100644
--- a/composer.json
+++ b/composer.json
@@ -16,10 +16,14 @@
"forum": "http://fuelphp.com/forums"
},
"require": {
- "php": ">=5.3.3",
+ "php": ">=5.4",
"monolog/monolog": "1.5.*",
"fuelphp/upload": "2.0.1"
},
+ "require-dev": {
+ "codeception/aspect-mock": "*",
+ "symfony/finder":"*"
+ },
"suggest": {
"mustache/mustache": "Allow Mustache templating with the Parser package",
"smarty/smarty": "Allow Smarty templating with the Parser package",
Install them with composer.
$ php composer.phar update
Change FuelPHP 1.7.1
Change FuelPHP to use AspectMock on testing.
diff --git a/fuel/app/bootstrap.php b/fuel/app/bootstrap.php
index a6213d5..b491688 100644
--- a/fuel/app/bootstrap.php
+++ b/fuel/app/bootstrap.php
@@ -1,9 +1,5 @@
<?php
-// Load in the Autoloader
-require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
-class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
-
// Bootstrap the framework DO NOT edit this
require COREPATH.'bootstrap.php';
diff --git a/oil b/oil
index 62033d6..4a21f80 100644
--- a/oil
+++ b/oil
@@ -48,6 +48,10 @@ define('COREPATH', realpath(__DIR__.'/fuel/core/').DIRECTORY_SEPARATOR);
defined('FUEL_START_TIME') or define('FUEL_START_TIME', microtime(true));
defined('FUEL_START_MEM') or define('FUEL_START_MEM', memory_get_usage());
+// Load in the Autoloader
+require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
+
// Boot the app
require APPPATH.'bootstrap.php';
diff --git a/public/index.php b/public/index.php
index e01d3a4..9cb90d3 100644
--- a/public/index.php
+++ b/public/index.php
@@ -40,6 +40,10 @@ define('COREPATH', realpath(__DIR__.'/../fuel/core/').DIRECTORY_SEPARATOR);
defined('FUEL_START_TIME') or define('FUEL_START_TIME', microtime(true));
defined('FUEL_START_MEM') or define('FUEL_START_MEM', memory_get_usage());
+// Load in the Autoloader
+require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
+
// Boot the app
require APPPATH.'bootstrap.php';
Configure AspectMock to load AspectMock ($kernel->init()
). If the configuration is wrong, AspectMock does not work fine.
diff --git a/bootstrap_phpunit.php b/bootstrap_phpunit.php
index 3b5b851..2605850 100644
--- a/bootstrap_phpunit.php
+++ b/bootstrap_phpunit.php
@@ -32,6 +32,34 @@ unset($app_path, $core_path, $package_path, $_SERVER['app_path'], $_SERVER['core
defined('FUEL_START_TIME') or define('FUEL_START_TIME', microtime(true));
defined('FUEL_START_MEM') or define('FUEL_START_MEM', memory_get_usage());
+/**
+ * Load the Composer autoloader if present
+ */
+defined('VENDORPATH') or define('VENDORPATH', realpath(COREPATH.'..'.DS.'vendor').DS);
+if ( ! is_file(VENDORPATH.'autoload.php'))
+{
+ die('Composer is not installed. Please run "php composer.phar update" in the root to install Composer');
+}
+require VENDORPATH.'autoload.php';
+
+// Add AspectMock
+$kernel = \AspectMock\Kernel::getInstance();
+$kernel->init([
+ 'debug' => true,
+ 'appDir' => __DIR__ . '/../',
+ 'includePaths' => [
+ __DIR__.'/../app', __DIR__.'/../core', __DIR__.'/../packages',
+ ],
+ 'excludePaths' => [
+ __DIR__.'/../app/tests', __DIR__.'/../core/tests',
+ ],
+]);
+
+// Load in the Autoloader
+//require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+$kernel->loadFile(COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php'); // path to your autoloader
+class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
+
// Boot the app
require_once APPPATH.'bootstrap.php';
(2014/03/21 Addition) I change the part of $kernel->init() as below, because the latest AspeckMock creates cache folders in current directory.
$kernel->init([
'debug' => true,
'appDir' => __DIR__ . '/../',
'includePaths' => [
APPPATH, COREPATH, PKGPATH,
],
'excludePaths' => [
APPPATH.'tests', COREPATH.'tests'
],
'cacheDir' => APPPATH.'tmp/AspectMock',
]);
(End of 2014/03/21 Addition)
To work AspectMock fine, copy fuel/core/phpunit.xml
to fuel/app/phpunit.xml
, and change backupGlobals
to false
.
--- fuel/core/phpunit.xml 2013-12-02 19:56:52.847375706 +0900
+++ fuel/app/phpunit.xml 2013-12-02 19:56:38.910935143 +0900
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
-<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php">
+<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php" backupGlobals="false">
<php>
<server name="doc_root" value="../../"/>
<server name="app_path" value="fuel/app"/>
In case of FuelPHP 1.8/develop (1.7.2 or later)
On 1.8/develop branch, changes above has been merged. So only change fuel/core/bootstrap_phpunit.php
,
diff --git a/bootstrap_phpunit.php b/bootstrap_phpunit.php
index 50b9c88..20678e7 100644
--- a/bootstrap_phpunit.php
+++ b/bootstrap_phpunit.php
@@ -40,8 +40,22 @@ if ( ! is_file(VENDORPATH.'autoload.php'))
}
require VENDORPATH.'autoload.php';
+// Add AspectMock
+$kernel = \AspectMock\Kernel::getInstance();
+$kernel->init([
+ 'debug' => true,
+ 'appDir' => __DIR__ . '/../',
+ 'includePaths' => [
+ __DIR__.'/../app', __DIR__.'/../core', __DIR__.'/../packages',
+ ],
+ 'excludePaths' => [
+ __DIR__.'/../app/tests', __DIR__.'/../core/tests',
+ ],
+]);
+
// Load in the Fuel autoloader
-require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+//require COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php';
+$kernel->loadFile(COREPATH.'classes'.DIRECTORY_SEPARATOR.'autoloader.php'); // path to your autoloader
class_alias('Fuel\\Core\\Autoloader', 'Autoloader');
// Boot the app
(2014/03/21 Addition) I change the part of $kernel->init() as below, because the latest AspeckMock creates cache folders in current directory.
$kernel->init([
'debug' => true,
'appDir' => __DIR__ . '/../',
'includePaths' => [
APPPATH, COREPATH, PKGPATH,
],
'excludePaths' => [
APPPATH.'tests', COREPATH.'tests'
],
'cacheDir' => APPPATH.'tmp/AspectMock',
]);
(End of 2014/03/21 Addition)
and create fuel/app/phpunit.xml
. That's it.
--- fuel/core/phpunit.xml 2013-12-02 19:56:52.847375706 +0900
+++ fuel/app/phpunit.xml 2013-12-02 19:56:38.910935143 +0900
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
-<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php">
+<phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php" backupGlobals="false">
<php>
<server name="doc_root" value="../../"/>
<server name="app_path" value="fuel/app"/>
How to write tests
Let's create a test for a controller with Response::redirect().
Response::redirect() will exit() for safty, phpunit exits and tests also stop. So normally you can't test it. But, replacing it with a test double, make it testable.
First, let's create a controller below.
<?php
class Controller_Test extends Controller
{
public function action_redirect()
{
return Response::redirect('welcome/404', 'location', 404);
}
}
Then, let's create a test.
<?php
// import Test class of AspectMock as test
use AspectMock\Test as test;
/**
* Tests for Controller_Test
*
* @group App
* @group Controller
*/
class Test_Controller_Test extends TestCase
{
protected function tearDown()
{
test::clean(); // remove all registered test doubles
}
public function test_redirect()
{
// replace Response::redirect() with a test double which only returns true
$req = test::double('Fuel\Core\Response', ['redirect' => true]);
// generate a request to 'test/redirect'
$response = Request::forge('test/redirect')
->set_method('GET')->execute()->response();
// confirm Response::redirect() was invoked with arguments below
$req->verifyInvoked('redirect', ['welcome/404', 'location', 404]);
}
}
Run test.
$ oil test --group=App
Tests Running...This may take a few moments.
PHPUnit 3.7.28 by Sebastian Bergmann.
Configuration read from /mnt/fuelphp/fuel/app/phpunit.xml
.
Time: 8.08 seconds, Memory: 48.50Mb
OK (1 test, 0 assertions)
Passed! It seems strange that "0 assertinos", but it can't be helped because we don't use assertion methods of PHPUnit.
Try to change the third argument from 404
to 405
in verifyInvoked().
$req->verifyInvoked('redirect', ['welcome/404', 'location', 405]);
$ oil test --group=App
Tests Running...This may take a few moments.
PHPUnit 3.7.28 by Sebastian Bergmann.
Configuration read from /mnt/fuelphp/fuel/app/phpunit.xml
F
Time: 7.72 seconds, Memory: 48.50Mb
There was 1 failure:
1) Test_Controller_Test::test_redirect
Expected Fuel\Core\Response::redirect('welcome/404','location',405) to be invoked but it never occur.
/mnt/fuelphp/fuel/vendor/codeception/aspect-mock/src/AspectMock/Proxy/Verifier.php:73
/mnt/fuelphp/fuel/app/tests/controller/test.php:28
FAILURES!
Tests: 1, Assertions: 0, Failures: 1.
Failed as expected.
You see that AspectMock can replace static methods with test doubles. Or it could redefine methods dynamically. So you can easily write applications tests. You don't need to use DI only for testability.
But ApsectMock is "Stability: alpha". So there might be still something wrong.
References
Date: 2014/01/10