Skip to content

Commit 08825f8

Browse files
committed
feature #23149 [PhpUnitBridge] Added a CoverageListener to enhance the code coverage report (lyrixx)
This PR was squashed before being merged into the 3.4 branch (closes #23149). Discussion ---------- [PhpUnitBridge] Added a CoverageListener to enhance the code coverage report | Q | A | ------------- | --- | Branch? | 3.4 | Bug fix? | no | New feature? | yes | BC breaks? | no | Deprecations? | no | Tests pass? | yes | Fixed tickets | - | License | MIT | Doc PR | symfony/symfony-docs#8416 --- The code coverage computed by PHPUnit is not very accurate by default as it marks a line as tested as soon as it has been executed. For example, if you have two classes A and B where A is using B and you write test only for the class A then the class B will be marked as tested. You can fix this issue by adding `@covers A` on top of the class ATest, but it's a bit boring. This Listener add this annotation on each test if it's applicable: * If an annotation already exists, we do nothing. * We try to find the SUT thanks to the Test class name, if it does not exist, we do nothing --- If you wan to see it in action: https://github.com/lyrixx/phpunit-auto-cover --- The PR is not finished, I think we could add this listener to symfony itself. What do you think? Commits ------- e17206d [PhpUnitBridge] Added a CoverageListener to enhance the code coverage report
2 parents 8a752c3 + e17206d commit 08825f8

15 files changed

+473
-0
lines changed

src/Symfony/Bridge/PhpUnit/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
3.4.0
5+
-----
6+
7+
* added a `CoverageListener` to enhance the code coverage report
8+
49
3.3.0
510
-----
611

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\PhpUnit;
13+
14+
use PHPUnit\Framework\BaseTestListener;
15+
use PHPUnit\Framework\Test;
16+
use Symfony\Bridge\PhpUnit\Legacy\CoverageListenerTrait;
17+
18+
if (class_exists('PHPUnit_Runner_Version') && version_compare(\PHPUnit_Runner_Version::id(), '6.0.0', '<')) {
19+
class_alias('Symfony\Bridge\PhpUnit\Legacy\CoverageListener', 'Symfony\Bridge\PhpUnit\CoverageListener');
20+
// Using an early return instead of a else does not work when using the PHPUnit
21+
// phar due to some weird PHP behavior (the class gets defined without executing
22+
// the code before it and so the definition is not properly conditional)
23+
} else {
24+
/**
25+
* CoverageListener adds `@covers <className>` on each test suite when possible
26+
* to make the code coverage more accurate.
27+
*
28+
* @author Grégoire Pineau <lyrixx@lyrixx.info>
29+
*/
30+
class CoverageListener extends BaseTestListener
31+
{
32+
private $trait;
33+
34+
public function __construct(callable $sutFqcnResolver = null, $warningOnSutNotFound = false)
35+
{
36+
$this->trait = new CoverageListenerTrait($sutFqcnResolver, $warningOnSutNotFound);
37+
}
38+
39+
public function startTest(Test $test)
40+
{
41+
$this->trait->startTest($test);
42+
}
43+
}
44+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\PhpUnit\Legacy;
13+
14+
/**
15+
* CoverageListener adds `@covers <className>` on each test suite when possible
16+
* to make the code coverage more accurate.
17+
*
18+
* @author Grégoire Pineau <lyrixx@lyrixx.info>
19+
*
20+
* @internal
21+
*/
22+
class CoverageListener extends \PHPUnit_Framework_BaseTestListener
23+
{
24+
private $trait;
25+
26+
public function __construct(callable $sutFqcnResolver = null, $warningOnSutNotFound = false)
27+
{
28+
$this->trait = new CoverageListenerTrait($sutFqcnResolver, $warningOnSutNotFound);
29+
}
30+
31+
public function startTest(\PHPUnit_Framework_Test $test)
32+
{
33+
$this->trait->startTest($test);
34+
}
35+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\PhpUnit\Legacy;
13+
14+
use PHPUnit\Framework\Test;
15+
use PHPUnit\Framework\Warning;
16+
17+
/**
18+
* PHP 5.3 compatible trait-like shared implementation.
19+
*
20+
* @author Grégoire Pineau <lyrixx@lyrixx.info>
21+
*
22+
* @internal
23+
*/
24+
class CoverageListenerTrait
25+
{
26+
private $sutFqcnResolver;
27+
private $warningOnSutNotFound;
28+
private $warnings;
29+
30+
public function __construct(callable $sutFqcnResolver = null, $warningOnSutNotFound = false)
31+
{
32+
$this->sutFqcnResolver = $sutFqcnResolver;
33+
$this->warningOnSutNotFound = $warningOnSutNotFound;
34+
$this->warnings = array();
35+
}
36+
37+
public function startTest($test)
38+
{
39+
$annotations = $test->getAnnotations();
40+
41+
$ignoredAnnotations = array('covers', 'coversDefaultClass', 'coversNothing');
42+
43+
foreach ($ignoredAnnotations as $annotation) {
44+
if (isset($annotations['class'][$annotation]) || isset($annotations['method'][$annotation])) {
45+
return;
46+
}
47+
}
48+
49+
$sutFqcn = $this->findSutFqcn($test);
50+
if (!$sutFqcn) {
51+
if ($this->warningOnSutNotFound) {
52+
$message = 'Could not find the tested class.';
53+
// addWarning does not exist on old PHPUnit version
54+
if (method_exists($test->getTestResultObject(), 'addWarning') && class_exists(Warning::class)) {
55+
$test->getTestResultObject()->addWarning($test, new Warning($message), 0);
56+
} else {
57+
$this->warnings[] = sprintf("%s::%s\n%s", get_class($test), $test->getName(), $message);
58+
}
59+
}
60+
61+
return;
62+
}
63+
64+
$testClass = \PHPUnit\Util\Test::class;
65+
if (!class_exists($testClass, false)) {
66+
$testClass = \PHPUnit_Util_Test::class;
67+
}
68+
69+
$r = new \ReflectionProperty($testClass, 'annotationCache');
70+
$r->setAccessible(true);
71+
72+
$cache = $r->getValue();
73+
$cache = array_replace_recursive($cache, array(
74+
get_class($test) => array(
75+
'covers' => array($sutFqcn),
76+
),
77+
));
78+
$r->setValue($testClass, $cache);
79+
}
80+
81+
private function findSutFqcn($test)
82+
{
83+
if ($this->sutFqcnResolver) {
84+
$resolver = $this->sutFqcnResolver;
85+
86+
return $resolver($test);
87+
}
88+
89+
$class = get_class($test);
90+
91+
$sutFqcn = str_replace('\\Tests\\', '\\', $class);
92+
$sutFqcn = preg_replace('{Test$}', '', $sutFqcn);
93+
94+
if (!class_exists($sutFqcn)) {
95+
return;
96+
}
97+
98+
return $sutFqcn;
99+
}
100+
101+
public function __destruct()
102+
{
103+
if (!$this->warnings) {
104+
return;
105+
}
106+
107+
echo "\n";
108+
109+
foreach ($this->warnings as $key => $warning) {
110+
echo sprintf("%d) %s\n", ++$key, $warning);
111+
}
112+
}
113+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Symfony\Bridge\PhpUnit\Tests;
4+
5+
use PHPUnit\Framework\TestCase;
6+
7+
class CoverageListenerTest extends TestCase
8+
{
9+
public function test()
10+
{
11+
if ("\n" !== PHP_EOL) {
12+
$this->markTestSkipped('This test cannot be run on Windows.');
13+
}
14+
15+
if (defined('HHVM_VERSION')) {
16+
$this->markTestSkipped('This test cannot be run on HHVM.');
17+
}
18+
19+
$dir = __DIR__.'/../Tests/Fixtures/coverage';
20+
$php = PHP_BINARY;
21+
$phpunit = $_SERVER['argv'][0];
22+
23+
exec("$php -d zend_extension=xdebug.so $phpunit -c $dir/phpunit-without-listener.xml.dist $dir/tests/ --coverage-text", $output);
24+
$output = implode("\n", $output);
25+
$this->assertContains('Foo', $output);
26+
27+
exec("$php -d zend_extension=xdebug.so $phpunit -c $dir/phpunit-with-listener.xml.dist $dir/tests/ --coverage-text", $output);
28+
$output = implode("\n", $output);
29+
$this->assertNotContains('Foo', $output);
30+
$this->assertContains("SutNotFoundTest::test\nCould not find the tested class.", $output);
31+
$this->assertNotContains("CoversTest::test\nCould not find the tested class.", $output);
32+
$this->assertNotContains("CoversDefaultClassTest::test\nCould not find the tested class.", $output);
33+
$this->assertNotContains("CoversNothingTest::test\nCould not find the tested class.", $output);
34+
}
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
3+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd"
5+
backupGlobals="false"
6+
colors="true"
7+
bootstrap="tests/bootstrap.php"
8+
failOnRisky="true"
9+
failOnWarning="true"
10+
>
11+
12+
<testsuites>
13+
<testsuite name="Fixtures/coverage Test Suite">
14+
<directory>tests</directory>
15+
</testsuite>
16+
</testsuites>
17+
18+
<filter>
19+
<whitelist>
20+
<directory>src</directory>
21+
</whitelist>
22+
</filter>
23+
24+
<listeners>
25+
<listener class="Symfony\Bridge\PhpUnit\CoverageListener">
26+
<arguments>
27+
<null/>
28+
<boolean>true</boolean>
29+
</arguments>
30+
</listener>
31+
</listeners>
32+
</phpunit>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
3+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd"
5+
backupGlobals="false"
6+
colors="true"
7+
bootstrap="tests/bootstrap.php"
8+
failOnRisky="true"
9+
failOnWarning="true"
10+
>
11+
12+
<testsuites>
13+
<testsuite name="Fixtures/coverage Test Suite">
14+
<directory>tests</directory>
15+
</testsuite>
16+
</testsuites>
17+
18+
<filter>
19+
<whitelist>
20+
<directory>src</directory>
21+
</whitelist>
22+
</filter>
23+
</phpunit>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace PhpUnitCoverageTest;
13+
14+
class Bar
15+
{
16+
private $foo;
17+
18+
public function __construct(Foo $foo)
19+
{
20+
$this->foo = $foo;
21+
}
22+
23+
public function barZ()
24+
{
25+
$this->foo->fooZ();
26+
27+
return 'bar';
28+
}
29+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace PhpUnitCoverageTest;
13+
14+
class Foo
15+
{
16+
public function fooZ()
17+
{
18+
return 'foo';
19+
}
20+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace PhpUnitCoverageTest\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
16+
class BarTest extends TestCase
17+
{
18+
public function testBar()
19+
{
20+
if (!class_exists('PhpUnitCoverageTest\Foo')) {
21+
$this->markTestSkipped('This test is not part of the main Symfony test suite. It\'s here to test the CoverageListener.');
22+
}
23+
24+
$foo = new \PhpUnitCoverageTest\Foo();
25+
$bar = new \PhpUnitCoverageTest\Bar($foo);
26+
27+
$this->assertSame('bar', $bar->barZ());
28+
}
29+
}

0 commit comments

Comments
 (0)