Autonomy of Unit Tests in PHPUnit

Hello everyone!


My name is Anton and now (not so long, about a year) I am developing in PHP in one big and old project. To ensure the quality of the project, we use autotests on the PHPUnit framework. But, unfortunately, it so happened that most of our autotests are functional. Recently, we realized that if we continue to use autotests in the same way, then soon the time spent on solving problems with not passing them to CI will become more than the time spent writing useful code (actually not, but it grows and it’s not nicely).


For a systematic solution to this problem, we chose an approach that involves writing a lot of unit tests and a minimum number of functional tests. We also decided that as part of the changes to the existing code necessary according to the scout rule, update existing tests. For a while - all this time we saw new features - everything was fine with us. But recently, a task flew to me to fix a bug in the old code. I started writing unit tests and realized that the ancient evil legacy woke up. It became clear that this is the road to nowhere:

  • I wrote a couple of test cases for several hours;
  • I realized that during this time I wrote a very, very many repeating code;
  • didn't actually do any useful work;
  • in the case of a change in DI, you will have to spend a lot of time on useless adaptation of the test code.


This article is about how I solved the problem of refusing to write valueless code. Under the cut - the final form of the test case, with the exception of most of the mechanical code



The title of the article refers to automation in the kanban style - on the one hand, we performed automation, and on the other hand, all key decisions are made by a person.

How did


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

As it was


Was bad. There was an endless spaghetti from the generation of mobs for dependencies, a lengthy call to the constructor in the test case. In general, it was ugly, and somewhere at the bottom of the article a noticeably cropped example of such a test is given.


A bit of pain in our conditions


Unfortunately, our project uses PHPUnit 3.x. Yes, I know that it ceased to be supported even before the 2014 (!) Year, but, unfortunately, we cannot refuse it on a local time scale.


Resolved Issues


, β€” , , . , β€” (, , 20) -, , , unit- , .


, , Β« Β» :

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



- . , . β€” . . - , . …

Consider repeating code fragments.


Mock Generation


Duplicate code snippet:

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

PHP has a good candidate for hiding monotonous this monotonous work - a magic function:

__get

But we still need to convey the type that we are wetting. And then a suitable tool was found - phpDoc:

@property Type $ service

In our case, especially taking into account the capabilities of modern IDEs, it turned out to be most convenient to set it in the following format:
@property \ FQCN \ Service | \ PHPUnit_Framework_MockObject_MockObject $ service

Inside the __get function of the abstract class Intellegence_PHPUnit_Framework_TestCase, metaprogramming occurs, which parses phpDoc and forms mocks on its basis, which are available as $ this-> propertyName in the test class. Attention, all the code given in the article is a little pseudo-code, the full version is in the repository:


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