Magento 2.4.0 comes with a major upgrade of the PHPUnit framework, which is used for all types of tests: Static, Unit, Integration, API Functional, and the Magento Functional Testing Framework. Magento Architects decided to make an upgrade to the latest PHPUnit version, which is 9.1.
Please be aware that the upgrade of PHPUnit is backwards incompatible.
The first approach started during Magento Commerce Global Contribution Day on April 4th. Together with the community, in scope of issue #27500, we started the migration. One week after, not even a half of work was done. The clock was ticking, as the 2.4
feature freeze was coming.
I tried to encourage Magento contributors to undertake the challenge.
"Use Rector"
appeared quite often instead. Out of curiosity, I tried to use existing Rectors. The first issue I noticed was the fact that Rector PHP expects correct PHP syntax (including annotations) and additionally, expects classes to exist. Otherwise, code refactoring fails.
We had to contend with:
Note: The first approach assumed that we need to migrate PHPUnit 6 to PHPUnit 8 first, as it was backwards-compatible, and then in a further release, upgrade to PHPUnit 9. Finally the Core Team said that we are allowed to migrate all the way to PHPUnit 9.
The Magento codebase includes over 30.000 unit tests (including Magento 2 Commerce, B2B and projects such as MSI). Some of them were just flaky (eg. environment-dependent) or invalid. The first step was to fix them and make sure these tests pass on all supported PHP versions.
All contributions to Magento 2 must follow the Definition of Done:
Pass Integrity Tests which include Static Code Analysis
Every single file modified in the Magento codebase has to follow coding standards. Most of the issues were fixed automatically by PHP Code Beautifier and PHP CS Fixer
Pass Unit Tests
Migration of unit tests included not only compatibility with PHPUnit 8, but also introduction of Strict Typing. This step revealed many, many hidden issues caused by Type Juggling. For example:
$this->assertTrue($validator->isValid($data))
is actually a tautology, because both true
and array
with an error was returning a true value:
return [
'status' => 'error',
'message' => __('Error message')
];
There was also an issue with handling Phrase
object with \PHPUnit\Framework\Assert::assertEquals
or \PHPUnit\Framework\MockObject\InvocationMocker::with
. Declaration of strict_types=1
"turns off" the Type Juggling, though ->__toString
has to be called explicitly on a Phrase object, otherwise assertEquals('Expected Error Message', $phraseObject)
failed.
There are two ways to fix this: assertEquals('Expected Error Message', $phraseObject->__toString())
or the preferred method: assertEquals('Expected Error Message', (string)$phraseObject)
The most time-consuming manual job was related to line length: the limit of 120 characters could not be fixed automatically. We had to edit the exception messages manually, wrapping them into multiple lines.
No one is capable of reviewing 30,000 of files submitted in scope of single PR. The decision was made to split the changes in scope of their module. Every single Module was separately reviewed and stabilized.
At this stage, PHPUnit Warnings and Notices were allowed.
Contributors who decided to face the challenge were given a separate Slack channel for synchronizing with the core team, as well as separate PHP 7.4-based branch. The goals were:
At the end of that stage, PHPUnit Warnings and Notices were not allowed.
PHPUnit 9 comes with very strict rules for Mocking objects. Invalid usage of \PHPUnit\Framework\TestCase::createPartialMock
for Interfaces and Abstract classes had to be replaced with getMockBuilder
and then configured. The \PHPUnit\Framework\MockObject\MockBuilder::setMethods
method was replaced with onlyMethods
and addMethods
.
What is the difference?
You need to explicitly define it if you mock an existing or non-existing method of a class or interface.
Magento internal teams were working on the API functional tests and integration tests, while the Community worked on unit tests. The final step was to synchronize the results and stabilize the outcome. Having magento/2.4-develop74
stabilized with PHP 7.4, these changes were merged to the mainline magento/2.4-develop
branch.
Our journey was done!
Such a big undertaking could not be done manually. That is why, step by step, we used automated tools and scripts to make the upgrade possible. This code was not expected to be published, which is why this is not a first-class example.
The natural way of delivering units of work is splitting the results by Module. That is why the shell scripts were focused on these scopes:
./migration.sh {Module name} [Custom directory]
Command | $TESTS_PATH |
---|---|
./migration.sh Catalog |
/var/www/html/app/code/Magento/Catalog/Test/Unit |
./migration.sh Staging .ee |
/var/www/html/.ee/app/code/Staging/Test/Unit |
Where [Custom directory]
was optional and was used to handle different products:
#!/bin/bash
set -e
BASE_DIR="/var/www/html/${2:-}"
TESTS_PATH="$BASE_DIR/app/code/Magento/$1/Test/Unit"
Working with different projects, we had to handle multiple Git repositories:
LocalGit () {
git --work-tree="$BASE_DIR" --git-dir="$BASE_DIR/.git" "$@"
}
Not all modules have unit tests, so our script must verify test availability:
test -d "$TESTS_PATH" || (echo "Module $1 does not contain Unit Tests in $TESTS_PATH" && exit 1)
and if the existing tests were not failing PHPUnit 6 before our modifications:
/var/www/html/phpunit8/vendor/bin/phpunit -c dev/tests/unit/phpunit6.xml $TESTS_PATH
There were plenty of issues with the PHP syntax, which is easy to fix with sed
:
Invalid arguments order for PHPDoc: $variable \Type
instead of \Type $variable
find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i 's/@var\s+(\$[^\s]+)\s+([\\|A-Za-z_]+)/@var \2 \1/g' {} \;
Introduce correct DOCBlock opening (replace /*
with /**
)
find $TESTS_PATH -name "*.php" -exec sed 's/\/\*$/\/\*\*/g' {} \; \
Remove redundant use
section alias
find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i 's/use \\?(PHPUnit[^;]+) as MockObject/use \1/g' {} \;
Remove redundant empty lines
find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i ':a;N;$!ba;s/\n\n\n/\n\n/g' {} \;
Replace Phrase
with string
for non LocalizedException
find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i 's/( \\[A-Za-z]+)\(__\(([^)]+)\)\)/\1(\2)/g' {} \;
Move declare()
introduced by PHP CS Fixer below Magento Copyright
find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i ':a;N;$!ba;s/(declare\(strict_types=1\);)\n(\/\*\*[^\/]+\/)/\n\2\n\1\n/g' {} \;
Once we had the PHP code ready for migration, we were finally able to use "The right tools for the job right". As recommended by the community, I decided to introduce PHPRector to the project using the Composer command composer require rector/rector --dev
.
This tool offers predefined set of rectors (scenarios), just like the PHPUnit 8.0 migration set. The backwards-compatibility with PHPUnit 6 had to be kept, which is why some rectors were excluded in our rector.yml
configuration file:
parameters:
auto_import_names: true
import_short_classes: false
import_doc_blocks: true
sets:
- 'phpunit80'
exclude_rectors:
- 'Rector\PHPUnit\Rector\MethodCall\SpecificAssertInternalTypeRector'
- 'Rector\PHPUnit\Rector\MethodCall\SpecificAssertContainsRector'
During the second stage of migration (PHPUnit 8 to PHPUnit 9 and drop for backwards-compatibility) the rector.yml
file was much more advanced:
parameters:
auto_import_names: true
import_short_classes: false
import_doc_blocks: true
sets:
- 'phpunit70'
- 'phpunit80'
- 'phpunit90'
- 'phpunit91'
- 'phpunit-specific-method'
- 'phpunit-mock'
services:
M2Coach\Rector\ReplacePartialMockRector: null
Rector\Renaming\Rector\MethodCall\RenameMethodCallRector:
$oldToNewMethodsByClass:
PHPUnit\Framework\Assert:
assertRegExp: assertMatchesRegularExpression
expectExceptionMessageRegExp: expectExceptionMessageMatches
PHPUnit\Framework\TestCase:
assertRegExp: assertMatchesRegularExpression
expectExceptionMessageRegExp: expectExceptionMessageMatches
Rector\Renaming\Rector\Class_\RenameClassRector:
$oldToNewClasses:
# https://github.com/sebastianbergmann/phpunit/issues/3123
PHPUnit\Framework\MockObject\Matcher\InvokedCount: 'PHPUnit\Framework\MockObject\Rule\InvokedCount'
PHPUnit\Framework\MockObject\Matcher\Invocation: 'PHPUnit\Framework\MockObject\Invocation'
PHPUnit_Framework_MockObject_MockObject: 'PHPUnit\Framework\MockObject\MockObject'
And that actually did most of the work.
Once we have the code ready for PHPUnit 9, we had to adjust the Code Style to follow Magento Coding Standard.
PHP CS Fixer (.php_cs
)
->setRules([
'@PSR2' => true,
'array_indentation' => true,
'array_syntax' => ['syntax' => 'short'],
'concat_space' => ['spacing' => 'one'],
'declare_strict_types' => true,
'hash_to_slash_comment' => true,
'include' => true,
'method_chaining_indentation' => true,
'method_argument_space' => true,
'modernize_types_casting' => true,
'new_with_braces' => true,
'no_empty_statement' => true,
'no_empty_comment' => true,
'no_empty_phpdoc' => true,
'no_extra_consecutive_blank_lines' => true,
'no_leading_import_slash' => true,
'no_leading_namespace_whitespace' => true,
'no_multiline_whitespace_around_double_arrow' => true,
'no_multiline_whitespace_before_semicolons' => true,
'no_singleline_whitespace_before_semicolons' => true,
'no_short_bool_cast' => true,
'no_trailing_comma_in_singleline_array' => true,
'no_unused_imports' => true,
'no_whitespace_in_blank_line' => true,
'object_operator_without_whitespace' => true,
'ordered_imports' => true,
'php_unit_set_up_tear_down_visibility' => true,
'standardize_not_equals' => true,
'ternary_operator_spaces' => true,
]);
PHP Mess Detector (default dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml
)
PHP Code Beautifier (config shared with Mess Detector)
Some of the issues reported by Mess Detector were fixed automatically with a script
#!/bin/bash
set -e
TOFIX="$(/var/www/html/vendor/bin/phpmd "$1" text /var/www/html/dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml)" || echo "Found $(echo $TOFIX | wc -l)"
[[ "$TOFIX" == "" ]] && exit 0
sed -i ':a;N;$!ba;s/\nclass/\n\/\*\*\n \* \@SuppressWarnings\(PHPMD.CouplingBetweenObjects\)\n \*\/\nclass/' $(echo "$TOFIX" | grep 'number of dependencies' | grep -oE '^([^:]+)' | tr '\n' ' ') || true
sed -i ':a;N;$!ba;s/\nclass/\n\/\*\*\n \* \@SuppressWarnings\(PHPMD.AllPurposeAction\)\n \*\/\nclass/' $(echo "$TOFIX" | grep 'processed HTTP methods' | grep -oE '^([^:]+)' | tr '\n' ' ') || true
sed -i ':a;N;$!ba;s/\n \*\/\n\/\*\*//' $(echo "$TOFIX" | grep -oE '^([^:]+)' | tr '\n' ' ') || true
/var/www/html/vendor/bin/phpmd "$1" text dev/tests/static/testsuite/Magento/Test/Php/_files/phpmd/ruleset.xml || exit 1
The automated part of migration was wrapped into a find
command that iterated through the module directories, executing Bash scripts. If the procedure failed, the information was written to a todo-manual.txt
file, to review manually.
#!/bin/bash
set -e
LocalGit () {
git --work-tree="$BASE_DIR" --git-dir="$BASE_DIR/.git" "$@"
}
BASE_DIR="/var/www/html/${2:-}"
TESTS_PATH="$BASE_DIR/app/code/$1/Test/Unit"
test -d "$TESTS_PATH" || (echo "Module $1 does not contains Unit Tests in $TESTS_PATH" && exit 1)
echo "=== Performing actions on $1 module ==="
(
find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i 's/@var\s+(\$[^\s]+)\s+([\\|A-Za-z_]+)/@var \2 \1/g' {} \; \
&& find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i ':a;N;$!ba;s/\n\n\n/\n\n/g' {} \; \
&& /var/www/html/vendor/bin/rector --config=/var/www/html/rector.yml process $TESTS_PATH \
&& find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i 's/use \\?(PHPUnit[^;]+) as MockObject/use \1/g' {} \; \
&& find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i "s/expectException\('\\?(.*?)'\)/expectException(\\\\\1::class)/g" {} \; \
&& find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i 's/( \\[A-Za-z]+)\(__\(([^)]+)\)\)/\1(\2)/g' {} \; \
&& (/var/www/html/vendor/bin/phpcbf --standard=/var/www/html/dev/tests/static/framework/Magento/ruleset.xml $TESTS_PATH; test $? -le 1 ) \
&& /var/www/html/vendor/bin/phpcs --standard=/var/www/html/dev/tests/static/framework/Magento/ruleset.xml $TESTS_PATH \
&& /var/www/html/vendor/bin/php-cs-fixer fix --config=/var/www/html/.php_cs $TESTS_PATH \
&& ( /var/www/html/fix-mess.sh "$TESTS_PATH" || echo "Mess fixer failed") \
&& find $TESTS_PATH -name "*.php" -exec sed --regexp-extended -i ':a;N;$!ba;s/(declare\(strict_types=1\);)\n(\/\*\*[^\/]+\/)/\n\2\n\1\n/g' {} \; \
&& /var/www/html/vendor/bin/phpunit --fail-on-warning -c dev/tests/unit/phpunit9.xml $TESTS_PATH \
&& LocalGit commit -m "#27500 PHPUnit9 for $1 module" $TESTS_PATH && git push lbajsarowicz
) || ( echo "$TESTS_PATH" >> todo-manual.txt && LocalGit reset --hard)
The code was not expected to be shared, so it is not "pretty". However, this way we were able to accomplish the entire PHPUnit 6 to PHPUnit 9 migration in a few weeks, and having them compatible with Magento coding standards.
As an extension developer, you can easily adjust the provided scripts to migrate existing PHPUnit tests to the latest version semi-automatically. If you followed the PHP coding standards during extension development, that should be enough to use PHP Rector with the configuration provided.
This project could not be accomplished without huge support of Slava Mankivski, Lena Orobei, Igor Sviziev. Community contributions could not be merged without the help of Oleksii Korshenko and Igor Miniailo.
I really appreciate the huge impact of Mediotype, who supported me the whole way! Special thanks to my competent colleagues, who shared their experience and expertise. Thank you, David Alger, for the Warden Development Environment and introducing PHP 7.4 support after my request.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.