Runkit + PHPUnit = 100% test coverage

    Dear colleagues.

    One of the indirect indicators of code quality is considered code coverage - the degree to which it is covered by tests (as a rule, we mean unit tests). In most cases, coverage is the ratio of the number of lines of code in which control falls during the test run to the total number of significant (not a comment, empty line, or, for example, one curly brace that indicates the beginning or end of a block) lines of the module code.

    Another condition for good tests is the absence of side effects, such as creating / deleting files, establishing network connections, writing to ports, etc.

    However, when it comes to a module that interacts with the outside world, these two requirements conflict. And okay, when it comes to file operations, when vfsStream comes to the rescue . But what to do when you need to test, say, direct work with sockets or code that uses curl_ * functions?

    Under the cut you will find my solution and, as a bonus, another OPP wrapper for the curl, fully covered with tests.


    You can use Runkit to write unit tests for such code .. I wrote an extension of PHPUnit's TestCase, which, with the help of the rankit, allows you to replace built-in functions using the full power of asserts and mocks from Sebastian Bergman. The heir is so simple that I allow myself to bring his code here in its entirety.

    1. namespace Base\UnitTest;
    2.  
    3. /**
    4. * Test case which enables overriding of functions with runkit.
    5. *
    6. * 1. To override a number of system function do
    7. *
    8. *              $mock = $this->runkitMockFunctions(array(...));
    9. *
    10. * 2. To define expected behaivour use $mock as an ordinary phpunit mock object.
    11. *
    12. * 3. To revert overridden functions back call $this->runkitRevertAll();
    13. *
    14. * Example:
    15. *
    16. *      class MyCurlTest
    17. *          extends \Base\UnitTest\RunkitTestCase
    18. *      {
    19. *          protected $mock;
    20. *
    21. *          protected function setUp()
    22. *          {
    23. *              $this->mock = $this->runkitMockFunctions(array(
    24. *                  'curl_init',
    25. *                  'curl_close',
    26. *              ));
    27. *          }
    28. *
    29. *          protected function tearDown()
    30. *          {
    31. *              $this->runkitRevertAll();
    32. *          }
    33. *
    34. *          public function testInitClose()
    35. *          {
    36. *              $this->mock
    37. *                  ->expects($this->at(0))
    38. *                  ->method('curl_init')
    39. *                  ->with()
    40. *                  ->will($this->returnValue('my_handle'));
    41. *
    42. *              $this->mock
    43. *                  ->expects($this->at(1))
    44. *                  ->method('curl_close')
    45. *                  ->with('my_handle');
    46. *
    47. *              $handle = curl_init();
    48. *              $this->assertEquals('my_handle', $handle);
    49. *              curl_close($handle);
    50. *          }
    51. *      }
    52. *
    53. * @package Base\UnitTest
    54. * @version $id$
    55. * @author Alexey Karapetov
    56. */
    57. abstract class RunkitTestCase
    58.     extends \PHPUnit_Framework_TestCase
    59. {
    60.     private static $mockedFunctions = array();
    61.  
    62.     const BACKUP_SUFFIX = '_runkit_mocker_backup';
    63.  
    64.     /**
    65.      * Method to call from overridden functions.
    66.      * Calls given mock's method with given arguments.
    67.      *
    68.      * @param string    $method Mock's method to call
    69.      * @param array     $args  Arguments to pass to @link $method
    70.      * @return void
    71.      */
    72.     public static function call($func, array $args)
    73.     {
    74.         return call_user_func_array(array(self::$mockedFunctions[$func], $func), $args);
    75.     }
    76.  
    77.     /**
    78.      * Mark test skipped if runkit is not enabled
    79.      *
    80.      * @return void
    81.      */
    82.     protected function skipTestIfNoRunkit()
    83.     {
    84.         if (!extension_loaded('runkit'))
    85.         {
    86.             $this->markTestSkipped('Runkit extension is not loaded');
    87.         }
    88.     }
    89.  
    90.     /**
    91.      * Override given functions with mock
    92.      *
    93.      * @param array $funcList Functions to override
    94.      * @return stdClass Mock object
    95.      */
    96.     protected function runkitMockFunctions(array $funcList)
    97.     {
    98.         $this->skipTestIfNoRunkit();
    99.  
    100.         $mock = $this->getMock('stdClass', $funcList);
    101.  
    102.         foreach ($funcList as $func)
    103.         {
    104.             $this->runkitOverride($func, '', 'return ' . __CLASS__ . "::call('{$func}', func_get_args());", $mock);
    105.         }
    106.  
    107.         return $mock;
    108.     }
    109.  
    110.     /**
    111.      * Override function
    112.      *
    113.      * @param string    $func
    114.      * @param string    $args
    115.      * @param string    $body
    116.      * @param mixed     $mock Mock object for the function
    117.      * @return void
    118.      */
    119.     protected function runkitOverride($func, $args, $body, $mock = null)
    120.     {
    121.         $this->skipTestIfNoRunkit();
    122.  
    123.         if (array_key_exists($func, self::$mockedFunctions))
    124.         {
    125.             throw new \RuntimeException("Function '{$func}' is marked as mocked already");
    126.         }
    127.         self::$mockedFunctions[$func] = $mock;
    128.         \runkit_function_copy($func, $func . self::BACKUP_SUFFIX);
    129.         \runkit_function_redefine($func, $args, $body);
    130.     }
    131.  
    132.  
    133.     /**
    134.      * Revert previously overridden function
    135.      *
    136.      * @param string $func
    137.      * @return void
    138.      */
    139.     protected function runkitRevert($func)
    140.     {
    141.         $this->skipTestIfNoRunkit();
    142.  
    143.         if (!array_key_exists($func, self::$mockedFunctions))
    144.         {
    145.             throw new \RuntimeException("Function '{$func}' is not marked as mocked");
    146.         }
    147.         unset(self::$mockedFunctions[$func]);
    148.  
    149.         \runkit_function_remove($func);
    150.         \runkit_function_copy($func . self::BACKUP_SUFFIX, $func);
    151.         \runkit_function_remove($func . self::BACKUP_SUFFIX);
    152.     }
    153.  
    154.     /**
    155.      * Revert all previously overridden functions
    156.      *
    157.      * @return void
    158.      */
    159.     protected function runkitRevertAll()
    160.     {
    161.         foreach (array_keys(self::$mockedFunctions) as $func)
    162.         {
    163.             $this->runkitRevert($func);
    164.         }
    165.     }
    166. }
    * This source code was highlighted with Source Code Highlighter.


    Attentive readers have already noticed the simplest example of use in the docking block of the class, I will gladly give explanations, if they are needed, in the comments to the topic. A slightly more complicated use case, testing the transfer of parameters by reference, you will find in the tests of the OOP wrapper to the Kurl that I promised .

    Thank you, I will be grateful for the criticism.

    Also popular now: