
Badoo was one of the first to switch to PHP 7 - we just
wrote about it recently. In that article, we talked about changes in the testing infrastructure and promised to tell you more about the replacement we developed for the runkit extension called SoftMocks.
Softmocks
The idea of ​​SoftMocks is very simple and is reflected in the name: you need to implement an analog for runkit, which is as compatible as possible with it in semantics, in pure PHP. Soft here emphasizes that it is implemented not inside the PHP core, but on top of it, without using the Zend API and other hardcore. The fact that it is in pure PHP means that we can safely upgrade to a new version of PHP and simply add support for the new syntax, rather than rewrite extensions with the new version of the Zend API and catch millions of bugs due to various subtleties in semantics.
In pure PHP, this can be done in the same way that many Go tools work, such as
godebug , go test -cover, etc. - automated rewriting of the code, in our case - on the fly, right before the "incluses". You can find the
AspectMock testing
framework on the
Go! Library on the Internet
! AOP , which also deals with rewriting code and provides the ability to write in AOP-style. The framework is good, but it does not allow completely replacing the runkit in our conditions, so we decided to write our solution in the image and likeness of this library. Unfortunately, the aforementioned framework does not provide the ability to intercept functions and methods on the fly (that is, without first announcing the intention to intercept a specific function). This is different from the behavior of runkit and uopz, although it also has its scope.
What runkit allows you to do
The runkit extension in PHP allows you to perform various manipulations on the state of objects, functions, methods and constants during the execution of PHP code.
')
Example from the documentation (http://php.net/manual/en/function.runkit-function-redefine.php).
Test program:
<?php function testme() { echo "Original Testme Implementation\n"; } testme(); runkit_function_redefine('testme','','echo "New Testme Implementation\n";'); testme();
Test program output:
Original Testme Implementation New Testme Implementation
Such runkit features are very widely used during functional and unit testing. Basically, the use of this extension is limited to the substitution of the implementation of methods, functions, and constant values.
API of our library
We would like to get the same functionality with the ability to override any functions and methods on the fly, without preliminary declarations. This is how the program looks like using SoftMocks instead of runkit (the same example):
<?php
The command to run the example is as follows:
$ php -r 'require("init.inc"); require(SoftMocks::rewrite("test.php"));'
The output of the test program is the same as in runkit.
The init.inc file contains the code for initializing the SoftMocks class and looks like this (the specific type of file will depend on your application):
<?php
Idea implementation
The initial idea was quite simple: we can wrap all calls to methods and functions, as well as calls to constants to calls to our wrapper, which checks whether there is a mock object for a particular method and function or not.
So the code from such
class A extends B { public function test($a, $b) { parent::test($a, $b); $c = file_get_contents("something.txt"); return $c; } }
will turn into this:
class A extends B { public function test($a, $b) { \QA\SoftMocks::call([parent::class, 'test'], [$a, $b]); $c = \QA\SoftMocks::call('file_get_contents', ['something.txt']); return $c; } }
The code for the SoftMocks :: call () method could then look like this:
public static function call($func, $args) { if (!self::isMocked($func)) { return call_user_func_array($func, $args); } return self::callMocks($func, $args); }
Start implementation: recursive rewriting include
First, we wrote a simple parser that could only do one thing - to substitute the include (...) and require (...) calls so that we could include the use of SoftMocks in the front controller or, for PHPUnit tests, in bootstrap.php , and all files would be recursively rewritten by our parser.
Example:
Here, autoload.php loads classes for an autoload project, registers and initializes everything you need, possibly loading some more files with include (...). The original file with the front controller must be moved to another location, for example, front-orig.php, and replaced with this:
After passing our parser, the front-orig.php file will look like this:
The SoftMocks :: rewrite ($ filename) method rewrites the file, replacing require, include, method calls, and so on with a call to wrappers. The return value of this function is the new path to the file that contains the already wrapped code and allows you to redefine the values ​​of functions, methods and constants on the fly.
For example,
front-orig.php
will be turned into
/tmp/mocks/<hash-code>/front-orig.php_<version>
. On the way to the compiled file, the <hash-code> is considered based on the content and the path to the file, which allows us to cache the compiled files and perform the procedure of parsing and rewriting the file only once.
At first, we wanted to rewrite only include and require in order to evaluate the complexity of full parsing. It turned out that PHP allows you not to use brackets for such constructions (that is, you can write
require "a.php";
instead of
require("a.php"))
, and expressions and calls to other functions are supported. This makes the simplest task of replacing "inclusions" more difficult than necessary. There are also constants __FILE__ and __DIR__, the values ​​of which change dynamically, depending on the location of the file. We often have code like
include(dirname(__DIR__) . “/something.php”);
, and calls to the constants __DIR__ and __FILE__ need to be replaced with their contents.
Another unpleasant problem was that it is possible to use relative paths in include (
require "a.php"
), and, accordingly, one should pay attention to the include_path setting and replace the current directory value (".") With the source directory, rather than rewritten file.
token_get_all () vs PHP Parser
The first version of our parser tried to use the token_get_all () function, which works very quickly and returns an array of tokens in the file. The problem is that on its basis it is very difficult to parse the nested function arguments and even more so to replace the argument lists with an array, as we need in the case of wrapping a function call in SoftMocks :: call ().
Therefore, we took the library of Nikita Popov called
PHP Parser . This library is able to build an AST tree based on the list of tokens returned to token_get_all (), and also provides convenient tools for traversing the tree and modifying it. The library makes it easy to implement exactly what we need.
Unfortunately, the parser has drawbacks:
- Poor performance: file parsing takes, according to our benchmarks, about 15 times longer than token_get_all ().
- The inability to print the modified tree back with the original line numbers.
If it is difficult to do something with the first problem, since the library is in PHP, then we have eliminated the second shortcoming by expanding the proposed out-of-the-box printer. For our purposes, it was not so important that the output file was “beautiful”, we only needed to preserve the original line numbers to the maximum so that the error messages in PHPUnit and the calculation of code coverage by tests did not suffer.
Final implementation
Based on PHP Parser, we quickly wrote a prototype that does exactly what we originally wanted - wraps calls to methods and functions into calls to its layer. Unfortunately, in the case of the methods, there were many problems with this approach:
- The call_user_func * family does not allow calling private and protected methods, so you need to use Reflection, which doesn’t have a good effect on performance.
- To call the parent method, you need to resort to "special street magic" - calls to parent methods are written as
parent::call_something(...)
, while the call is not really static, but dynamic. In addition, the value of the static class should be preserved, and not point to the parent class. Unfortunately, we did not find a simple way to save the current static context when making calls via Reflection - probably there is no such method yet. - Since we always call methods via Reflection using setAccessible (true), we, in fact, always call private and protected methods as if they were public, whereas in "real" code this could lead to a Fatal error during execution . It turns out that we are changing the behavior of the code under test, which is impermissible.
- It is not possible in this way to override the implementation for "magic" methods, for example, for __construct, and also __get, __set, __clone, __wakeup, etc.
As a result, we came to the conclusion that we will carry out mock-objects for class methods by inserting additional code before each method definition. Example
public function doSomething($a) { return $a > 5; }
will turn into the following:
public function doSomething($a) { if (SoftMocks::isMocked(...)) { return eval(SoftMocks::getMockCode(...)); } return $a > 5; }
We do not wrap method calls, but we still do it for functions. This approach does not allow intercepting methods of the built-in classes, however, surprisingly, we did not need this opportunity. Interestingly, the AspectMock library uses a similar approach for mock method objects.
When working with the functions of the problem, too.
- Some functions depend on the current context, for example, get_called_class ().
- Values ​​like
static
(string) and self
can be passed to the function, and since the function is called through our wrapper, functions get different values ​​for these keywords. In such cases, it is required to modify the test code so that the function does not pass strings, but class names, for example, static::class
instead of static
. - Functions that can call a callback, for example, preg_replace_callback, can call private methods. Since the real call to the preg_replace_callback function comes from the SoftMocks class, an access error occurs and private methods from this context become inaccessible. The solution to the problem is also rewriting the code, for example, passing anonymous functions instead of
array($this, 'callback')
.
To solve most of these problems, we made support for the "black list" of functions that do not turn around and are always called directly. Here are some of them: get_called_class, get_parent_class, func_get_args, usort, array_walk_recursive, extract, compact, get_object_vars. These functions cannot be changed using SoftMocks.
Interception of global constants and class constants is very simple: we replace all calls to constants with function calls. The only exceptions are cases in which constants are specified as default values ​​in the arguments of functions or class properties. That is, the following places we can not rewrite:
class A { private $b = SOME_CONST; }
We also decided not to wrap the constants true, false and null for performance reasons. This means that, unlike the runkit, SoftMocks will not be able to
redefineConstant("true", false);
.
Performance
Since SoftMocks is written in pure PHP, one would expect its performance to be worse than runkit. In fact, our tests began to run faster and more stable, since SoftMocks does not suffer from such problems as the need to reset the runtime cache during any call. Our library does not suffer from performance degradation with an increase in the number of loaded classes and functions, so the overall performance in our case turned out to be even slightly better.
If you do not use the SoftMocks functions at all, but still execute the rewritten code, then its performance, according to our estimates, decreases by about 3 times. In general, we would recommend using SoftMocks for unit tests and not using this library in production for both performance and security reasons: the library creates temporary files and includes from a directory that can be written from the web context.
PHPUnit integration
Since we replace the paths to files that are “included”, the backtrace becomes unreadable due to auto-generated files instead of the original. Also, since PHPUnit itself downloads files, and we don’t rewrite its source code, this makes it impossible to replace the functions and methods defined in test files.
To solve these problems, we prepared a pull-request for PHPUnit:
github.com/sebastianbergmann/phpunit/pull/2116Conclusion
Our SoftMocks project is posted on GitHub at:
github.com/badoo/soft-mocks .
We use Nikita Popov's PHP Parser, which is also available on GitHub:
github.com/nikic/PHP-Parser .
We ensured that when rewriting the code on the fly, it was possible to substitute the implementation of functions, user-defined methods and constants - and all this in pure PHP, without using third-party extensions. In our case, we were able to completely get rid of runkit, “drive out” our entire suite of 60,000 unit tests for PHP 7 and fix the incompatibilities found (there were very few of them, and some of them are errors in dev versions of PHP 7, o which we told the developers).
Currently, badoo.com is working on PHP 7, and we were able to achieve this, including through the development of SoftMocks. I hope your experience will be as positive as ours.
Enjoy testing!
Yuri Nasretdinov, senior PHP developer