Pull to refresh

Runkit + PHPUnit = 100% покрытие тестами

Reading time 7 min
Views 3.6K
Здравствуйте, уважаемые коллеги.

Одним из косвенных показателей качества кода считается code coverage — степень покрытия его тестами (как правило, имеются в виду модульные тесты). В большинстве случаев за coverage принимается соотношение количеству строк кода, в котрое попадает управление во время прогона тестов, к общему числу значимых (не являющихся комментарием, пустой строкой, или, например одной фигурной скобкой, обозначающей начало или конец блока) строк кода модуля.

Другим же условием хороших тестов является отсутствие сторонних эффектов (side effects), как например создание/удаление файлов, установка сетевых соединений, запись в порты и т.д.

Однако, когда дело касается модуля, взаимодествующего с внешним миром, эти два требования вступают в противоречие. И ладно, если речь идет о файловых операциях, когда на помощь приходит vfsStream. Но что делать, когда надо тестировать, скажем, прямую работу с сокетами или код, использующий функции curl_*?

Под катом вы найдете мое решение и, в качестве бонуса, еще одну ОПП-обертку к курлу, полностью покрытую тестами.


Для написания модульных тестов на такой код можно использовать Runkit. Я написал расширение PHPUnit'овского TestCase, которое с помощью ранкита позволяет заменять встроенные функции, используя всю мощь ассертов и моков от Себастьяна Бергмана. Наследник получился настолько простым, что я позволю себе привести его код здесь целиком.

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


Простейший пример использования внимательные читатели уже заметили в док-блоке класса, я с удовольствием дам пояснения, если они будут нужны, в комментах к топику. А чуть более сложный вариант использования, тестирующий передачу параметров по ссылке, вы найдете в тестах обещанной мной OOP-обертки к курлу.

Спасибо, буду признателен за критику.
Tags:
Hubs:
+31
Comments 16
Comments Comments 16

Articles