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

Tags: english