Using Assertions with (Legacy) PHP Code

hakre-pantheon-preserveWhile it was not much advised to use assertions (the assert PHP language construct) prior to PHP 7 due to the fact that it actually eval’ed a string’ed code, these days are gone. This is probably a lesser known fact with all the other immense improvements PHP 7 and 7.1 came with, so I’d like to take the opportunity with this post to highlight the PHP assertion feature that comes with zero run-time overhead and zero side-effects for production code.

hakre-pantheon-line-upSo after you brought your legacy code-base to run under the latest PHP version 7(.1) (and perhaps soon 7.2?) you might want to benefit from the improvements assertions went under with PHP version 7.0.

In a nutshell, the old-style “string” assertions still work (luckily they are retired), but instead you want to benefit from the parser improvements and just write a PHP expression in there:

    'Assert that the $result is bool'

You write it just as a PHP expression:

    'Assert that $result is bool, ' . gettype($result) . ' '
    . var_export($result, true) . ' given'

The message given (second “parameter”, actually second expression) will trigger an AssertionError which either will be a true Error Throwable (PHP ini setting assert.exception is “1”) or will trigger a warning with that message.

This does not happen automatically. The assertion is only in action when the PHP ini setting zend.assertions is “1”. If it is “0” the assertion code will be generated but skipped at run-time (ignored). And if “-1”, it is dropped, it’s not feeding into the parser at all. Yes that means zero run-time overhead with the “-1” production setting.

You can see that if you enabled code-coverage with the default setting  “0” the segment if on multiple lines within the assert(...) is not covered.


So as prior to PHP 7 the behaviour depends on PHP ini settings, in specific the zend.assertions which controls how the assert “calls” are handled (e.g. you can set it that in production code it is never “called”, the assertions are just dropped when PHP loads the file, “-1” – production mode), generated but skipped (“0”, this ensures linting) or fully in action (“1”, development mode). This ini-setting needs to be set in the php.ini file.

This basically means you need to plan which systems should support assertions so that development can benefit from it. It does not belong into production, but you want it in tests and while developing.

The other switch to move is assert.exception that is for turning assertion warnings into actual AssertionError objects. This has the benefit you can catch them and you directly know in a central place if an assertion failed. Make the setting “1” for that (prior to PHP 7 the ASSERT_CALLBACK handler was more useful but more complicated for that, now you can handle it within a generic Exception/ (throwable) Error handler).

PHP 7 Assert Configuration

The TLDR short and sweet: In development, configure zend.assertions to “1” and if you want to fail early (e.g. in testing and while developing), set assert.exception to “1” as well.

For production, set the shields down and progress as if the world is in order: Set zend.assertions to “-1”  and set assert.exception to “0” so that if you want to switch warning logging on occasionally for debugging (for that set zend.assertions again to “1” to get the warnings) – but actually not for live, perhaps for integration. Your production code must not depend on assertions. These are meant for debugging.


Throwing Unit-Tests with Coverage into Legacy-Code

Now to the non-legacy code you definitely want. As legacy-code is code without tests (so adding an assertion here or there works great with legacy code), for new code you want to ensure that if that new code contains assertions these are fully tested.

This is especially useful if you create new code under test that is made to interact with legacy-code. For example if you allow to inject a call-back and you’ve put your new code under strict-typing (declare(strict_types=1);) the call-back return value is most likely expected to be of a certain type that you want to assert. You can still cast the return value to make it work well with strict types, but nevertheless you want the assertion in effect for a better error message and not taint the new production code with past implications and workarounds.

 * @inheritdoc
public function match(string $subject): bool
    $return = call_user_func($this->callback, $subject);
        'Assert that callback for matcher returns bool, '
        . gettype($return) . ' '
        . var_export($return , true) . ' given'
    return (bool)$return;

Example: Code that invokes a callback you have absolutely no real control of what is been called in the end and also what it returns, could be for example a WordPress action/filter callback but here this is exemplary any valid callable. The method here uses strict-type hinting of the return value and therefore expects the callback to return a boolean. As the callback might be legacy code just returning some bool-ish value, a cast to bool is fine but asserting an actual bool is returned is the contract to fulfill. The assertion is used here to maintain stable, fail-safe operation while signalling problematic use in a development environment and/or testing.


Phpunit as an Example

So that assertion needs to be covered within in your tests. As the test depends on PHP run-time configuration you can’t even influence via a phpunit.xml configuration file as those above named PHP ini-setting zend.assertions is not configurable at run-time (which makes sense as the dropping of the assertions is done while parsing a PHP file). So what you want is to ensure that the test-suite runs with zends.assertions set to “1”. Make this visible by failing a test that covers the assertion or make it skip:

class MyTest extends TestCase
    function testNonBoolCallbackReturnTriggersAssertion()
        if ('1' !== ini_get('zend.assertions')) {
            $this->markTestSkipped('zend.assertions must be "1"');

This makes it visible when running the test-suite that assertions needs to be switched on for that particular test.

Now how about testing the actual assertion triggered a warning or an AssertionError? in Phpunit that can be easily done by providing an expected exception. As Phpunit turns warnings into exceptions internally when those happen, this is easy to expect: If assertion.exceptions is on, we’re expecting an AssertionError and if not a PHPUnit\Framework\Error\Warning. Just set-up your test-case for that within the test method and you can finally trigger the assertion:

            ('1' === ini_get('assert.exception'))
            ? 'AssertionError'
            : 'PHPUnit\Framework\Error\Warning'

        ... invoke code with failing assertion ...

        $this->fail('an expected to fail assertion did not happen');

Running the test with code-coverage on will show you not only that the assertion under test is actually executed but you can also verify it triggered the un-happy path.

That done you can bring together legacy code with new, fully tested one.


Assertions on the Run

With correct settings in development, testing and integration environments you can keep your production servers running while ditching the bugs out and tightening processing (strict types for the win). While assertions are triggered in testing  you can verify potentially violating processing for newly  written code that interacts with legacy one with ease. Same applies to old code, assertions are for debugging. Let them highlight expectation violations while not changing a single line of code in production thanks to PHP 7.

Read on in the PHP Manual about assertions:

Images based on Parthenon from West, filters by Fx-Foundry

This entry was posted in Developing, Hakre's Tips, PHP Development, PHP Development, Pressed, The Know Your Language Department, Tools and tagged , , , , , , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s