Wrapping PHP Functions for Testability

  • Posted by Mike Naberezny in PHP,Testing

    One of the problems that hampers the testability of PHP code is the coupling created by accessing all of the PHP global functions. This happens often because a large number of useful extensions are accessed only through global functions. Consider the following code snippet:

    $res = ldap_connect($host, $port);
    if (! $res) {
      // error logging
      return false;
    }

    There are two code paths shown above: the connection succeeding, and it failing. Both of them are very difficult to test because of the coupling to the global function ldap_connect() provided by the LDAP extension.

    To make it succeed, you’d need an LDAP server. Causing it to fail is easier but it could take a very long time until the connection timeout occurs. Also, the code can’t be tested at all without the LDAP extension. All of these problems are unacceptable.

    The solution is to use to the extension through an object instead of calling the extension function directly. This way, we can inject either the extension wrapper or a mock object for testing.

    However, writing these wrappers and maintaining them can be a pain and this is often the rationale given for not using them. There’s an easy answer to this excuse:

    class ExtensionProxy {
      protected $ext;
     
      public function __construct($ext) {
        $this->ext = $ext;
      }
     
      public function __call($method, $args) {
        return call_user_func_array("{$this->ext}_{$method}", $args);
      }
    }

    Since most PHP extensions prefix all of their functions with the name followed by an underscore, it’s easy to wrap them with something like the class above.

    There’s some performance penalty from call_user_func_array() in the above example but you can always write out a class later if that ever actually becomes a problem. Meanwhile, it can get you going very going quickly.

    Our connection example then simply becomes:

    $ldap = new ExtensionProxy('ldap');
     
    ...
     
    $res = $ldap->connect($host, $port);
    if (! $res) {
      // error logging
      return false;
    }

    The difference in usage is trivial but this version is easily testable. It now depends only on an $ldap instance, which the class needing LDAP can receive in its constructor. To test, now just pass a mock object for $ldap.

    The technique of putting lightweight wrappers around PHP extension functions has been around for a long time. For example, Horde has a small wrapper around the IMAP extension for testing.

    The continued improvements in PHP 5 allow for simple tricks like the ExtensionProxy above, and advances in tools like PHPUnit are making tests increasingly convenient and practical.

    Whatever methods you choose, there really is no excuse for untested (or untestable) PHP code these days. I consider anything without good tests to be broken and you should also.

4 comments

  • comment by Lukas 2 Aug 07

    I guess one could also use runkit to rewrite the functions on the file. Never really used the extension, but I hope that there is a way to call the original implementation if one must.

  • comment by Tomek 17 Dec 07

    I’ve just realized how flowed the architecture of PHP is (at least in case of testability) if I can’t redefine any functions or classes in global scope.
    I need to write some test session handling some code and I cannot replace the orginal implementation with fakes without modifying the production code as described in this blog-entry.
    Does anybody know a way to work-around this ? Is there aby extention to PHP Core that allows of redefinition of function so I could redefine functions ?

  • comment by Andrew 4 Feb 08

    @Tokem: See the extension Lukas alluded to; http://us.php.net/runkit

  • comment by Hari KT 1 Jul 14

    Thank you for the post.

    This is really an awesome one still people need to learn.

Post a comment


Thanks for commenting. Please remember to be nice to others and keep your comment relevant to the discussion. I reserve the right to remove comments that don't meet these simple guidelines.