استقلالية اختبارات الوحدة في PHPUnit

تحية للجميع!


اسمي أنطون والآن (منذ وقت ليس ببعيد ، أقوم بالتطوير في PHP في مشروع واحد كبير وقديم. لضمان جودة المشروع ، نستخدم الاختبارات التلقائية في إطار عمل PHPUnit. ولكن ، لسوء الحظ ، حدث أن معظم الاختبارات الذاتية لدينا تعمل. لقد أدركنا مؤخرًا أنه إذا واصلنا استخدام الاختبارات الذاتية بالطريقة نفسها ، فإن الوقت الذي يقضيه في حل المشكلات مع عدم تمريرها إلى CI سيصبح قريبًا أكثر من الوقت المستغرق في كتابة التعليمات البرمجية المفيدة (في الواقع لا ، لكنه ينمو ولا بشكل جميل).


من أجل حل منهجي لهذه المشكلة ، اخترنا نهجًا ينطوي على كتابة الكثير من اختبارات الوحدة والحد الأدنى من الاختبارات الوظيفية. قررنا أيضًا أنه كجزء من التغييرات على الشفرة الحالية اللازمة وفقًا لقاعدة الكشفية ، قم بتحديث الاختبارات الحالية. لفترة من الوقت - كل هذا الوقت رأينا ميزات جديدة - كل شيء كان على ما يرام معنا. ولكن في الآونة الأخيرة ، توجهت إلي مهمة لإصلاح خطأ في التعليمات البرمجية القديمة. بدأت كتابة اختبارات الوحدة وأدركت أن إرث الشر القديم استيقظ. أصبح من الواضح أن هذا هو الطريق إلى العدم:

  • كتبت بضع حالات اختبار لعدة ساعات.
  • أدركت أنه خلال هذا الوقت كتبت رمزًا متكررًا جدًا ؛
  • في الواقع لم يقم بأي عمل مفيد ؛
  • في حالة حدوث تغيير في DI ، سيكون عليك قضاء الكثير من الوقت على التكيف غير المجدي لرمز الاختبار.


تتناول هذه المقالة كيفية حل مشكلة رفض كتابة رمز لا قيمة له. تحت القطع - الشكل النهائي لحالة الاختبار ، باستثناء معظم الشفرة الميكانيكية



يشير عنوان المقالة إلى الأتمتة في أسلوب kanban - من ناحية ، قمنا بأتمتة ، ومن ناحية أخرى ، يتم اتخاذ جميع القرارات الرئيسية من قبل شخص.

كيف فعل


<?php declare(strict_types=1);
namespace Company\Bundle\Tests\Unit\Service;

use PHPUnit_Framework_MockObject_MockObject;

/**
 * @method \Company\Bundle\Service\TestedService getTestedClass()
 * @property PHPUnit_Framework_MockObject_MockObject|\Company\AnotherBundle\Repository\DependencyRepository $dependencyRepository
 * @property ConstDependencyInjectionParameter $diConstParam
 */
class TestedServiceTest extends Intelligent_PHPUnit_Framework_TestCase
{
    /**
     * @throws \Exception
     */
    public function test_getData_DataDescripion_ResultDescription(): void
    {
        // data
        $this
            ->diConstParam
            ->set("value")
        ;

        // behaviour
        $this
            ->dependencyRepository
            ->expects($this->once())
            ->method('findData')
            ->with(10, 10)
            ->will($this->returnValue([]))
        ;

        // SUT
        $clazz = $this->getTestedClass();

        // act
        $res = $clazz->getData();

        // asserts
    }
}

كما كان


كان سيئا. كان هناك معكرونة لا نهاية لها من جيل الغوغاء من أجل التبعيات ، مكالمة طويلة للمنشئ في حالة الاختبار. بشكل عام ، كان قبيحًا ، وفي مكان ما أسفل المقالة ، يتم تقديم مثال مقصوص بشكل ملحوظ لهذا الاختبار.


القليل من الألم في ظروفنا


للأسف ، يستخدم مشروعنا PHPUnit 3.x. نعم ، أعلم أنها توقفت عن دعمها حتى قبل عام 2014 (!) ، ولكن للأسف ، لا يمكننا رفضها على نطاق زمني محلي.


قضايا حلها


, — , , . , — (, , 20) -, , , unit- , .


, , « » :

  1. setUp.
  2. tearDown. , ,
  3. -.



- . , . — . . - , . …

ضع في اعتبارك تكرار أجزاء التعليمات البرمجية.


الجيل الوهمي


مقتطف رمز مكرر:

$this->service = $this
    ->getMockBuilder(Service::class)
    ->disableOriginalConstructor()
    ->getMock()
;

PHP لديه مرشح جيد لإخفاء هذا العمل الرتيب - وظيفة سحرية:

__احصل على

لكننا ما زلنا بحاجة إلى نقل النوع الذي نراهن عليه. ثم تم العثور على أداة مناسبة - phpDoc:

property اكتب $ service

في حالتنا ، خاصة مع مراعاة قدرات IDE الحديثة ، اتضح أنه الأكثر ملاءمة لتعيينها بالتنسيق التالي:
property \ FQCN \ Service | \ PHPUnit_Framework_MockObject_MockObject $ service $

داخل وظيفة __get للفئة المجردة Intellegence_PHPUnit_Framework_TestCase ، تحدث عملية البرمجة ، التي تحلل phpDoc وتشكل نماذج على أساسها ، والتي تتوفر على أنها $ this-> propertyName في فئة الاختبار. انتباه ، كل الكود الوارد في المقالة هو كود زائف صغير ، النسخة الكاملة موجودة في المستودع:


abstract class Intelligent_PHPUnit_Framework_TestCase extends PHPUnit_Framework_TestCase {
   public function __get(string $name)
    {
        // propertyBag     ,        phpDoc property
        $property = $this->getPropertyBag()->get($name);
        if ($property === false) {
            throw new Exception("Invalid property name: ${name}");
        }

        return $property;
    }

    private function getPropertyBag(): PropertyBag
    {
            if ($this->propertyBag === null) {
               //     
               $this->propertyBag = (new DocCommentParser())
                   ->parse(
                        //    phpDoc   FQCN .   FQCN  ,      
                        get_class($this),
                        //     callback,   getMockBuilder -     unit-
                        function (string $className): PHPUnit_Framework_MockObject_MockObject {
                            return $this->createDefaultObjectMock($className);
                        }
                    );
            }

            return $this->propertyBag;
    }
}

, phpDoc . :


class DocCommentParser
{
    /**
     * @param string $className
     * @param callable $mockCreator
     * @throws Exception
     * @return PropertyBag
     */
    public function parse(
        string $className,
        callable $mockCreator
    ): PropertyBag {
        /** @var string[] $res */
        $r = new ReflectionClass($className);
        //   phpDoc
        $docComment = $r->getDocComment();

        if ($docComment === false) {
            return new PropertyBag([]);
        }

        return $this->parseDocString($docComment, $mockCreator);
    }

    private function parseDocString(
        string $docComment,
        callable $mockCreator
    ): PropertyBag {
        /** @var string[] $map */
        $map = [];
        /** @var string[] $constructorParams */
        $constructorParams = [];

        //   -  : property Type1|Type2 $propertyName
        //    mock,   DI     ,   
        preg_match_all('/@property\s*(?<mock>(\S*)\|(\S+)\s*\$(\S+))/', $docComment, $matches, PREG_SET_ORDER);
        foreach ($matches as $match) {
            if (array_key_exists('mock', $match) && !empty($match['mock'])) {
                //        -    
                //   
                if ($this->isMockObject($match[2])) {
                    $map[$match[4]] = $mockCreator($match[3]);
  
                    continue;
                }

                if ($this->isMockObject($match[3])) {
                    $map[$match[4]] = $mockCreator($match[2]);

                    continue;
                }
            }
        }

        return new PropertyBag($map);
    }
}

? , — FQCN phpDoc property . — , .


2
.

-


class ServiceTest extends PHPUnit_Framework_TestCase
{
    /** @var DependencyRepository|PHPUnit_Framework_MockObject_MockBuilder */
    private $repository;
    protected function setUp()
    {
        parent::setUp();
        $this
            ->repository = $this->getMockBuilder(DependencyRepository::class)
            ->disableOriginalConstructor()
            ->getMock()
        ;    
    }

    protected function tearDown()
    {
        $this->repository = null;
        parent::tearDown();
    }

    public function test_test()
    {
         // $this->repository = ...
    }
}


/** @property PHPUnit_Framework_MockObject_MockObject|\Company\AnotherBundle\Repository\DependencyRepository $repository */
class ServiceTest extends Intelligent_PHPUnit_Framework_TestCase
{
    public function test_test()
    {
         // $this->repository = ...
    }
}


3-4-5 ( ) — . , , , 7 — . , 20 — -.



. ! -, , .


:


public function test_case()
{
    // SUT
    //   , ,    DI    .
    // ,      DI.
    $this->clazz = $this->createTestClass(
        $dependency1,
        $dependency2,
        $dependency3 //, ...
    );
}

, . php __call, phpDoc method.


, , ?! , property! , . , property — . — , .


, :


/**
 * @method FQCN\TestedService getTestedClass()
 */
class ServiceTest
{
    public function test_case()
    {
         // SUT
         $clazz = $this->getTestedClass();
    }
}

__call:


abstract class Intelligent_PHPUnit_Framework_TestCase extends PHPUnit_Framework_TestCase
{
    private const GET_TESTED_CLASS_METHOD_NAME = 'getTestedClass';

    /**
     * @param string $method
     * @param array $params
     * @throws Exception
     * @return object
     */
    public function __call(string $method, array $params)
    {
        if ($method !== self::GET_TESTED_CLASS_METHOD_NAME) {
            throw new Exception("Invalid method name: ${method}");
        }

        if (!array_key_exists(self::GET_TESTED_CLASS_METHOD_NAME, $this->getMethodsMap())) {
            throw new Exception('Method ' . self::GET_TESTED_CLASS_METHOD_NAME . ' not annotated');
        }

        //  ,  PropertyBag        property -    
        $params = $this->getPropertyBag()->getConstructorParams();
        $paramValues = [];
        foreach ($params as $param) {
            $property = $this->__get($param);
            $paramValues[] = $property;
        }

        $reflection = new ReflectionClass($this->getMethodsMap()[self::GET_TESTED_CLASS_METHOD_NAME]);
        //         
        return $reflection->newInstanceArgs($paramValues);
    }
}

? property:


class DocCommentParser
{
    /**
     * @param string $className
     * @param callable $mockCreator
     * @throws Exception
     * @return PropertyBag
     */
    public function parse(
        string $className,
        callable $mockCreator
    ): PropertyBag {
        /** @var string[] $res */
        $r = new ReflectionClass($className);
        $docComment = $r->getDocComment();

        if ($docComment === false) {
            //     
            return new PropertyBag([], []);
        }

        return $this->parseDocString($docComment, $mockCreator);
    }

    private function parseDocString(
        string $docComment,
        callable $mockCreator
    ): PropertyBag {
        /** @var string[] $map */
        $map = [];
        /** @var string[] $constructorParams */
        $constructorParams = [];

        preg_match_all('/@property\s*(?<mock>(\S*)\|(\S+)\s*\$(\S+))/', $docComment, $matches, PREG_SET_ORDER);
        foreach ($matches as $match) {
            if (array_key_exists('mock', $match) && !empty($match['mock'])) {
                if ($this->isMockObject($match[2])) {
                    $map[$match[4]] = $mockCreator($match[3]);
                    //    
                    $constructorParams[] = $match[4];

                    continue;
                }

                if ($this->isMockObject($match[3])) {
                    $map[$match[4]] = $mockCreator($match[2]);
                    //    
                    $constructorParams[] = $match[4];

                    continue;
                }
            }
        }

        return new PropertyBag($map, $constructorParams);
    }
}

. , . , DI - , , , — ,


-


class ServiceTest extends PHPUnit_Framework_TestCase
{
    /** @var DependencyRepository|PHPUnit_Framework_MockObject_MockBuilder */
    private $repository;
    protected function setUp()
    {
        parent::setUp();
        $this
            ->repository = $this->getMockBuilder(DependencyRepository::class)
            ->disableOriginalConstructor()
            ->getMock()
        ;    
    }

    protected function tearDown()
    {
        $this->repository = null;
        parent::tearDown();
    }

    public function test_test()
    {
        $this->clazz = $this->createTestClass(repository);
    }

    private function createTestClass() {
        return new TestedService($repository)
    }
}


— . .

3
.


ConstDependencyInjectionParameter. , .



All Articles