Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce "Initial Tests Run" stage time by running only covering test files #1919

Merged
merged 7 commits into from
Mar 20, 2024

Conversation

maks-rafalko
Copy link
Member

@maks-rafalko maks-rafalko commented Mar 17, 2024

This PR:

Originally requested here: #1451

Idea explanation

If we have X tests files and run Infection with for example infection --git-diff-lines and only 1 file is updated/added - it does not make sense to run all the X tests to generate coverage if we know that only 1 file of X covers the source code file.

In practice, PHPUnit even has different methods that help define which test file covers which class:

With the following code:

use use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(Calculator::class)]
class CalculatorTest extends TestCase {}

we can say that Calculator class is covered by CalculatorTest test class, and vice-versa. So, from Infection point of view, when we use --git-diff-lines and someone creates PR with updated Calculator class, we can run only CalculatorTest and skip other X test files (as they anyway don't add any useful coverage), saving a lot of time.

Things to note

  • Implemented feature doesn't change default Infection behavior and can be enabled by adding --map-source-class-to-test, so it's opt-in
  • implementation is intentionally simple but already gives a huge win, see below. But I designed it in such a way that it can be extended to map source class to test class with more complex strategies

Example with PHP-CS-Fixer

After maintainers of PHP-CS-Fixer decided to integrate Infeciton into their pipeline, I was analysing why "Initial Tests Run" stage takes so much time when even only 1 file is changed in PR.

It turned out, that PHP-CS-Fixer has ~24,000 unit tests, that all are executed when we change 1 source file.

At the same time, PHP-CS-Fixer has @covers annotation on all test classes, and we can 100% map which test class covers which source class, and run only covering test classes instead of all ~24,000 tests.

Before:

FAST_LINT_TEST_CASES=1 infection.phar --git-diff-lines

    ____      ____          __  _
   /  _/___  / __/__  _____/ /_(_)___  ____
   / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
 _/ // / / / __/  __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/  \___/\___/\__/_/\____/_/ /_/

#StandWithUkraine

Infection - PHP Mutation Testing Framework version dev-master

Running initial test suite...

PHPUnit version: 10.5.13

 24768 [============================] 4 mins

After:

FAST_LINT_TEST_CASES=1 infection.phar --git-diff-lines --map-source-class-to-test

    ____      ____          __  _
   /  _/___  / __/__  _____/ /_(_)___  ____
   / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \
 _/ // / / / __/  __/ /__/ /_/ / /_/ / / / /
/___/_/ /_/_/  \___/\___/\__/_/\____/_/ /_/

#StandWithUkraine

Infection - PHP Mutation Testing Framework version dev-master

Running initial test suite...

PHPUnit version: 10.5.13

   41 [============================] 12 secs

Difference:

- 24768 [============================] 4 mins
+    41 [============================] 12 secs

So, for a simple PR with 1 file chaged, now "Initital Tests Run" runs 41 tests by 12 seconds instead of 24,768 tests by 4 minutes.

Technical comments

I said the current implementation is intentionally simple, meaning the following: currently, we are not finding which test class covers particular changed file by finding all the CoversClass attributes / @covers annotations, but just make an assumption that if you have Calculator class, then test that covers it is named CalculatorTest.

Actually this is true for Infection, this is how we map Source Class to Test Class:

public static function getTestClassName(string $sourceClassName): string
{
if (strpos($sourceClassName, 'Infection\\Tests') !== false) {
return $sourceClassName . 'Test';
}
return preg_replace('/Infection/', 'Infection\\Tests', $sourceClassName, 1) . 'Test';
}

and this is also true for PHP-CS-Fixer, they also have such mapping from Source Class to Test Class:

https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/3b3bfe90fe8d5d920328a1f36288d2dfacf5d6b3/tests/AutoReview/ProjectCodeTest.php#L73-L78

Since we now know that Calculator class is covered by CalculatorTest test class, we can very simply build a --filter option for "Initial Tests Run" stage, just by configuring regex:

--filter='CalculatorTest|SomeOtherClassTest'

I've named this "strategy" of mapping source class to test class as simple. Later, if needed, we can implement more complex non-standard strategies, e.g. the one that will phisically parse all files from tests folder and find those classes that have CoversClass(Calculator::class), if we need more strict mapping.

But I intentionally didn't do it at this point.

array_map(
static fn (SplFileInfo $sourceFile): string => sprintf('%sTest', $sourceFile->getBasename('.' . $sourceFile->getExtension())),
$this->filteredSourceFilesToMutate,
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 10 lines actually the real implementation of the feature that needs attention.

as I wrote in the description, this is a simple strategy that maps SourceClass to SourceClassTest

@maks-rafalko maks-rafalko force-pushed the feature/1451-reduce-initial-tests-run-time branch 3 times, most recently from 6138a30 to ddc9d72 Compare March 17, 2024 20:12
…ble "Initial Tests Run" test files filtering

Implements #1451
@maks-rafalko maks-rafalko force-pushed the feature/1451-reduce-initial-tests-run-time branch from ddc9d72 to e675584 Compare March 17, 2024 20:22
@theofidry theofidry self-assigned this Mar 17, 2024
/**
* @internal
*/
final class MapSourceClassToTestStrategy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use enums for this purposes.

And to avoid nullable values technically we could use NONE as an option instead of NULL

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought about it. But intentionally didn't use Enums because we have all the "enum-like" classes as usual classes with constants. So it would be strange to have somewhere enums and somewhere classes with constants - I decided to be consistent here.

If we decide to use enums - we can replace all the classes at once as a separate PR. Currently, 0 enums are in the code base.

src/Command/RunCommand.php Outdated Show resolved Hide resolved
src/Command/RunCommand.php Outdated Show resolved Hide resolved
iterator_to_array($this->sourceFileFilter->filter($this->infectionConfig->getSourceFiles()))
));
/** @var list<SplFileInfo> $files */
$files = iterator_to_array($this->sourceFileFilter->filter($this->infectionConfig->getSourceFiles()));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this change (that is a bit noisy as it is affecting a few method signatures / phpstan / mutations) be extracted?

static fn (SplFileInfo $file) => $file->getRealPath(),
iterator_to_array($this->sourceFileFilter->filter($this->infectionConfig->getSourceFiles()))
));
/** @var list<SplFileInfo> $files */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is that phpdoc really necessary?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

short answer: yes, because deeper in the stack we have return type iterable<SplFileInfo|Trace> and here we need to specify it.

In other word - this type was here, and removing of this @var produces a phpstan error.

I tried quickly to replace iterable<SplFileInfo|Trace> with iterable<T> in the stack - but that produces too many errors that need to be analyzed separately. Something wrong with types across those classes.

@@ -70,7 +72,8 @@ public static function create(
array $sourceDirectories,
bool $skipCoverage,
bool $executeOnlyCoveringTestCases = false,
array $filteredSourceFilesToMutate = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated, but we could update the CS fixer to append the trailing commas all the time to avoid this sort of diff

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, extracted to #1920

src/TestFramework/PhpUnit/Adapter/PestAdapterFactory.php Outdated Show resolved Hide resolved
@@ -123,6 +141,34 @@ public function buildForMutant(string $configPath, string $extraOptions, array $
return $options;
}

private function mapSourceClassToTestClass(SplFileInfo $sourceFile): string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be static

/**
* @return list<string>
*/
private function prepareArgumentsAndOptions(string $configPath, string $extraOptions): array
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be static

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not a common practice in Infection's codebase, so I decided to leave it as is.

If we decide to use static methods every time there is no $this in it - let's discuss it and probably integrate a fixer? But I wonder what's the benefit and whether it worth doing it.

# Conflicts:
#	src/Command/RunCommand.php
#	src/Configuration/Configuration.php
#	src/Configuration/ConfigurationFactory.php
#	src/Container.php
#	src/TestFramework/Factory.php
#	src/TestFramework/PhpUnit/Adapter/PestAdapterFactory.php
#	src/TestFramework/PhpUnit/Adapter/PhpUnitAdapterFactory.php
#	src/TestFramework/PhpUnit/CommandLine/ArgumentsAndOptionsBuilder.php
#	tests/benchmark/Tracing/provide-traces-closure.php
#	tests/phpunit/Configuration/ConfigurationAssertions.php
#	tests/phpunit/Configuration/ConfigurationFactoryTest.php
#	tests/phpunit/Configuration/ConfigurationTest.php
#	tests/phpunit/ContainerTest.php
#	tests/phpunit/TestFramework/PhpUnit/CommandLine/ArgumentsAndOptionsBuilderTest.php
maks-rafalko added a commit to infection/site that referenced this pull request Mar 20, 2024
maks-rafalko added a commit to infection/site that referenced this pull request Mar 20, 2024
@maks-rafalko maks-rafalko merged commit 6d55979 into master Mar 20, 2024
55 checks passed
@maks-rafalko maks-rafalko deleted the feature/1451-reduce-initial-tests-run-time branch March 20, 2024 07:48
@maks-rafalko
Copy link
Member Author

Wanted to share:

Screenshot_20240321_005630_GitHub.jpg

PHP-CS-Fixer/PHP-CS-Fixer#7874 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants