Autonomía de pruebas unitarias en PHPUnit

¡Hola a todos!


Mi nombre es Anton y ahora (no hace tanto tiempo, aproximadamente un año) estoy desarrollando en PHP en un proyecto grande y antiguo. Para garantizar la calidad del proyecto, utilizamos pruebas automáticas en el marco PHPUnit. Pero, desafortunadamente, sucedió que la mayoría de nuestras pruebas automáticas son funcionales. Recientemente, nos dimos cuenta de que si continuamos usando las pruebas automáticas de la misma manera, pronto el tiempo dedicado a resolver problemas al no pasarlos a CI será más que el tiempo dedicado a escribir código útil (en realidad no, pero crece y no es bien).


Para una solución sistemática a este problema, elegimos un enfoque que implica escribir muchas pruebas unitarias y un número mínimo de pruebas funcionales. También decidimos que, como parte de los cambios necesarios en el código existente de acuerdo con la regla de exploración, actualizar las pruebas existentes. Por un tiempo, todo este tiempo vimos nuevas características, todo estuvo bien con nosotros. Pero recientemente, una tarea voló hacia mí para corregir un error en el código anterior. Comencé a escribir pruebas unitarias y me di cuenta de que el antiguo legado del mal despertó. Quedó claro que este es el camino a ninguna parte:

  • Escribí un par de casos de prueba durante varias horas;
  • Me di cuenta de que durante este tiempo escribí un código muy, muy repetido;
  • en realidad no hizo ningún trabajo útil;
  • En el caso de un cambio en la DI, tendrá que dedicar mucho tiempo a la adaptación inútil del código de prueba.


, . — ,



— , — .


<?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. -.



- . , . — . . - , . …

Considere repetir fragmentos de código.


Simulacro de generación


Fragmento de código duplicado:

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

PHP tiene un buen candidato para ocultar monótono este trabajo monótono, una función mágica:

__obtener

Pero aún necesitamos transmitir el tipo que estamos mojando. Y luego se encontró una herramienta adecuada: phpDoc:

@property Type $ service

En nuestro caso, especialmente teniendo en cuenta las capacidades de los IDEs modernos, resultó más conveniente configurarlo en el siguiente formato:
@property \ FQCN \ Service | \ PHPUnit_Framework_MockObject_MockObject $ service

Dentro de la función __get de la clase abstracta Intellegence_PHPUnit_Framework_TestCase, se produce la metaprogramación, que analiza phpDoc y forma simulacros sobre la base, que están disponibles como $ this-> propertyName en la clase de prueba. Atención, todo el código dado en el artículo es un pequeño pseudocódigo, la versión completa está en el repositorio:


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