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;
class TestedServiceTest extends Intelligent_PHPUnit_Framework_TestCase
{
public function test_getData_DataDescripion_ResultDescription(): void
{
$this
->diConstParam
->set("value")
;
$this
->dependencyRepository
->expects($this->once())
->method('findData')
->with(10, 10)
->will($this->returnValue([]))
;
$clazz = $this->getTestedClass();
$res = $clazz->getData();
}
}
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- , .
, , Β« Β» :
- setUp.
- tearDown. , ,
- -.
- . , . β . . - , . β¦
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)
{
$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(
get_class($this),
function (string $className): PHPUnit_Framework_MockObject_MockObject {
return $this->createDefaultObjectMock($className);
}
);
}
return $this->propertyBag;
}
}
, phpDoc . :
class DocCommentParser
{
public function parse(
string $className,
callable $mockCreator
): PropertyBag {
$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 {
$map = [];
$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]);
continue;
}
if ($this->isMockObject($match[3])) {
$map[$match[4]] = $mockCreator($match[2]);
continue;
}
}
}
return new PropertyBag($map);
}
}
? , β FQCN phpDoc property . β , .
-class ServiceTest extends PHPUnit_Framework_TestCase
{
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()
{
}
}
class ServiceTest extends Intelligent_PHPUnit_Framework_TestCase
{
public function test_test()
{
}
}
3-4-5 ( ) β . , , , 7 β . , 20 β -.
. ! -, , .
:
public function test_case()
{
$this->clazz = $this->createTestClass(
$dependency1,
$dependency2,
$dependency3
);
}
, . php __call, phpDoc method.
, , ?! , property! , . , property β . β , .
, :
class ServiceTest
{
public function test_case()
{
$clazz = $this->getTestedClass();
}
}
__call:
abstract class Intelligent_PHPUnit_Framework_TestCase extends PHPUnit_Framework_TestCase
{
private const GET_TESTED_CLASS_METHOD_NAME = 'getTestedClass';
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');
}
$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
{
public function parse(
string $className,
callable $mockCreator
): PropertyBag {
$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 {
$map = [];
$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
{
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)
}
}
β . .
ConstDependencyInjectionParameter. , .