< zurück

Overwrite PHP’s built-in functions in unit tests

von Manuel Strehl

Abgelegt unter

Schlüsselwörter: ,

In unit tests you might run in problems, when your code uses PHP built-in functions, that emit certain hard-coded values like session_start(). When you use PHP namespaces, however, you can solve this problem in an elegant way.

The trick is quite simple, once you know it. When you are in namespaced code and call a function, PHP will first search your namespace for that function, and then fall back to the global namespace, if it doesn’t find a match.

Prerequisites

So for this to work, you need these two aspects implemented in your code:

  1. Use a namespace, and
  2. do not call global functions explicitly, that is, not like \strlen($foo).

Then you can leverage the fallback behaviour to your advantage: In a bootstrap file for your unit tests define functions in your namespace, that match the ones you want to overwrite. Then return the desired value from them to be used in your unit tests. The following bootstrapping code is sufficient to shadow session_status:

<?php
namespace MyNamespace;

/* always claims an active session */
function session_status() {
    return PHP_SESSION_ACTIVE;
}

In your code, when calling session_status(), it will then always state an active session:

<?php
namespace MyNamespace;

/* calls our mock function */
$session_is_active = session_status();

/* don't do this, though! */
$session_is_active = \session_status();

(Note, that your code and the mocking function need to be in the same namespace!)

Refinement

The simple approach has a couple shortcomings. For one, the mocked function cannot decide, from where it was called. It may well be, that you need true in ine test, but false in another.

debug_backtrace comes to the rescue! We can simply loop through the stack and see, where the mocking function was called:

<?php
namespace MyNamespace;

/* sometimes claims an active session */
function session_status() {
    $backtrace = debug_backtrace();
    foreach ($backtrace as $step) {
        if ($step['function'] === 'needInactiveSession') {
            return PHP_SESSION_NONE;
        }
    }

    return PHP_SESSION_ACTIVE;
}

When one of the functions/methods in the stack is named needInactiveSession, the mock now returns an inactive session.

Then we might, for some reasons, return the original function’s value. This is achieved straight-forward. The combo call_user_func_array and func_get_args allows this to be extremely flexible for all cases.

<?php
namespace MyNamespace;

/* sometimes claims an active session */
function session_status() {
    return call_user_func_array('\\session_status', func_get_args());
}

Third, the bootstrapping code might accidentally be loaded in a production environment. Do we have a possibility to narrow or even remedy the impact of such an error? Yes. With both previous methods combined, we can scan the calling stack for trigger functions and classes, like unit test class names, and return mocked values only then. In all other cases we return the value of the PHP built-in. Adequate naming assumed this makes it completely safe to mock built-in functions, even when the mocks end up in the call path.

And last but not least, you will find yourself writing very much mocking boilerplate in this way. Wouldn’t it be nice to abstract that away in a single function? The code should look like this:

<?php
namespace MyNamespace;

function session_status() {
    return _mock(array(
        'called_from_this_function' => PHP_SESSION_DISABLED,
        'CalledFromAnyMethodInThisClass' => PHP_SESSION_ACTIVE,
        'OtherClass::specificMethod' => PHP_SESSION_NONE,
        /* any stacktrace with none of the above should return the original
         * value of \session_status() */
    ));
}

Implementation

Here is a complete function for mocking functions, that implements the above requirements:

<?php
namespace MyNamespace;

/**
 * mock return values for built-in functions in unit tests
 *
 * Usage: Create a function MyNamespace\php_built_in to shadow
 * a PHP built-in php_built_in(). Define its mock behaviour in terms
 * of calling stack. If any of the calling functions/methods are detected,
 * return the given value:
 *
 *     function php_built_in() {
 *         return _mock(array(
 *             'a_function' => 42,
 *             'MyClass' => true,
 *             'OtherClass::otherMethod' => 'foobar',
 *         ));
 *     }
 *
 * This way, calling php_built_in will either return `42` when the function
 * `a_function` is involved, `true` for calls where any method of `MyClass`
 * occurs in the stack, `foobar` for `OtherClass::otherMethod` and a true call
 * to php_built_in if neither is in the stack.
 *
 * You can also use _mock to just fiddle with the arguments but still return
 * the original function:
 *
 *     function php_built_in() {
 *         $args = func_get_args();
 *         // change some arguments in $args and then...
 *         return _mock(array(), $args);
 *     }
 *
 */
function _mock($when=array(), $args=null) {
    $backtrace = debug_backtrace();
    array_shift($backtrace); // self
    $latest = array_shift($backtrace); // calling function to be mocked
    $function = str_replace(__NAMESPACE__.'\\', '', $latest['function']);

    /* you can provide your own arguments. If not, take the ones from the
     * backtrace */
    if ($args === null) {
        $args = $latest['args'];
    }

    /* loop through invoking methods/functions. If one matches a key in
     * $when, return $when's value */
    foreach ($when as $invoke => $value) {
        foreach ($backtrace as $step) {
            if (

                /* "MyClass" or "MyClass::myMethod" */
                (array_key_exists('class', $step) &&
                 ($step['class'] === $invoke ||
                  $step['class'].'::'.$step['function'] === $invoke)) ||

                /* "my_function" */
                (! array_key_exists('class', $step) &&
                 $step['function'] === $invoke)

               ) {
                return $value;
            }
        }
    }

    /* return PHP's built-in function call */
    return call_user_func_array("\\$function", $args);
}

(Licensing trifles: You can use above code under both GPL and MIT licenses. Choose at your liking.)