PHPUnit 并行测试配置:利用 Paratest 工具加速大型项目的测试执行时间
大家好,今天我们来聊聊如何利用 Paratest 工具,通过并行执行 PHPUnit 测试,来显著加速大型项目的测试执行时间。对于稍具规模的项目而言,完整的测试套件运行时间往往令人难以忍受,尤其是在持续集成 (CI) 环境下,快速的测试反馈至关重要。Paratest 正是解决这一痛点的利器。
1. 问题背景:串行测试的瓶颈
传统的 PHPUnit 测试执行方式是串行的,即一个测试文件执行完毕后,才会开始下一个。这意味着,即使你的服务器拥有多核 CPU,也只有一个核心在忙碌地运行测试代码,其他核心处于闲置状态。对于包含大量集成测试或需要访问外部资源(如数据库)的测试,这种串行执行方式会造成严重的性能瓶颈。
例如,假设一个项目有 1000 个测试用例,每个用例平均耗时 0.1 秒,那么完整的测试套件需要 100 秒才能执行完成。这在开发过程中是一个无法接受的等待时间。
2. Paratest 简介:并行测试的解决方案
Paratest 是一个 PHPUnit 的并行测试执行器。它能够将测试套件分割成多个独立的进程,并在多个 CPU 核心上同时运行这些进程。通过这种方式,可以显著缩短测试执行时间,提高开发效率。
Paratest 的核心思想是:将测试文件分发给多个 PHP 进程,每个进程独立运行其分配到的测试文件,然后将结果汇总起来。这就像将一个大的计算任务分配给多个工作人员同时处理,最后汇总结果一样。
3. Paratest 的安装与配置
首先,我们需要安装 Paratest。推荐使用 Composer 进行安装:
composer require brianium/paratest --dev
安装完成后,Paratest 会被安装在 vendor/bin/paratest 目录下。为了方便使用,可以将它添加到系统的 PATH 环境变量中,或者直接使用 Composer 的脚本功能。
接下来,我们需要配置 PHPUnit 的 phpunit.xml 文件。虽然 Paratest 本身不需要特殊的 PHPUnit 配置,但为了更好地利用并行测试的优势,我们需要注意以下几点:
- 数据库隔离: 如果测试涉及到数据库操作,必须确保每个测试进程使用独立的数据库连接,以避免数据冲突。这可以通过创建多个数据库,并在测试启动时动态切换连接来实现。
- 文件系统隔离: 类似地,如果测试涉及到文件系统操作,也需要确保每个测试进程使用独立的文件目录,以避免文件冲突。
- 避免全局状态: 尽可能避免在测试中使用全局变量或静态变量,因为这些变量可能会被多个测试进程共享,导致意外的结果。
4. Paratest 的基本用法
Paratest 的基本用法非常简单,只需在命令行中执行以下命令:
paratest
Paratest 会自动检测项目中的 PHPUnit 测试,并将它们分发给多个进程并行执行。
可以通过 -p 或 --processes 选项指定并行进程的数量。例如,要使用 4 个进程并行执行测试,可以执行以下命令:
paratest -p 4
Paratest 会根据你的 CPU 核心数量自动选择一个合适的进程数量。一般来说,进程数量等于 CPU 核心数量是最佳的选择,但也可以根据实际情况进行调整。
5. 数据库隔离的实现
数据库隔离是并行测试中一个非常重要的环节。以下是一个简单的数据库隔离实现示例:
首先,在 phpunit.xml 文件中定义多个数据库连接:
<phpunit ...>
<php>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="testing_db_1"/>
</php>
<testsuites>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
</phpunit>
然后,在测试的 setUp() 方法中,动态切换数据库连接:
<?php
namespace TestsFeature;
use IlluminateFoundationTestingRefreshDatabase;
use IlluminateFoundationTestingWithFaker;
use TestsTestCase;
use IlluminateSupportFacadesDB;
class ExampleTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// 从环境变量中获取数据库名称
$databaseName = env('DB_DATABASE');
// 动态切换数据库连接
config(['database.connections.mysql.database' => $databaseName]);
DB::purge('mysql'); // 清除缓存的连接
DB::reconnect('mysql'); // 重新连接
}
public function testBasicTest()
{
$response = $this->get('/');
$response->assertStatus(200);
}
}
在这个示例中,我们首先从环境变量 DB_DATABASE 中获取数据库名称,然后使用 config() 函数动态修改数据库连接配置,最后使用 DB::purge() 和 DB::reconnect() 方法清除缓存的连接并重新连接。
为了让每个测试进程使用不同的数据库,我们需要在运行 Paratest 时,为每个进程设置不同的 DB_DATABASE 环境变量。这可以通过 Paratest 的 --runner 选项来实现。
创建一个自定义的 Paratest Runner:
<?php
namespace AppParatest;
use ParaTestRunnersPHPUnitRunner;
use SymfonyComponentConsoleInputInputInterface;
use SymfonyComponentConsoleOutputOutputInterface;
class DatabaseAwareRunner extends Runner
{
protected function doRun(InputInterface $input, OutputInterface $output, array $argv = array())
{
$originalArgv = $argv;
$processCount = $this->options['processes'];
$databasePrefix = 'testing_db_';
for ($i = 1; $i <= $processCount; $i++) {
$env = ['DB_DATABASE' => $databasePrefix . $i];
$argv = array_merge($originalArgv, ['--env' => json_encode($env)]);
parent::doRun($input, $output, $argv); // 使用原始的 $argv
}
return $this->exitCode;
}
}
然后,在运行 Paratest 时,指定这个自定义 Runner:
paratest --runner App\Paratest\DatabaseAwareRunner -p 4
当然,在实际项目中,可以使用更加完善的数据库管理方案,例如使用 Docker Compose 创建多个数据库容器,并在测试启动时动态创建和销毁数据库。
6. 文件系统隔离的实现
文件系统隔离的实现与数据库隔离类似。首先,在 phpunit.xml 文件中定义一个文件存储目录:
<phpunit ...>
<php>
<env name="FILESYSTEM_ROOT" value="./storage/testing"/>
</php>
<testsuites>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
</phpunit>
然后,在测试的 setUp() 方法中,创建独立的测试目录:
<?php
namespace TestsFeature;
use IlluminateFoundationTestingRefreshDatabase;
use IlluminateFoundationTestingWithFaker;
use TestsTestCase;
use IlluminateSupportFacadesStorage;
class ExampleTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// 从环境变量中获取文件系统根目录
$filesystemRoot = env('FILESYSTEM_ROOT');
// 为当前测试进程创建独立的测试目录
$processId = getmypid(); // 获取当前进程ID
$testDirectory = $filesystemRoot . '/' . $processId;
// 确保目录存在
if (!is_dir($testDirectory)) {
mkdir($testDirectory, 0777, true);
}
// 设置文件系统根目录
config(['filesystems.disks.local.root' => $testDirectory]);
Storage::fake('local'); // 使用假的 Storage 门面
$this->testDirectory = $testDirectory;
}
protected function tearDown(): void
{
// 清理测试目录
if (is_dir($this->testDirectory)) {
$this->rrmdir($this->testDirectory);
}
parent::tearDown();
}
private function rrmdir($dir) {
if (is_dir($dir)) {
$objects = scandir($dir);
foreach ($objects as $object) {
if ($object != "." && $object != "..") {
if (is_dir($dir."/".$object))
$this->rrmdir($dir."/".$object);
else
unlink($dir."/".$object);
}
}
rmdir($dir);
}
}
public function testBasicTest()
{
// 在独立的测试目录中创建文件
Storage::disk('local')->put('example.txt', 'Hello, world!');
$this->assertTrue(Storage::disk('local')->exists('example.txt'));
}
}
在这个示例中,我们首先从环境变量 FILESYSTEM_ROOT 中获取文件系统根目录,然后使用 getmypid() 函数获取当前进程 ID,并为当前测试进程创建一个独立的测试目录。最后,我们使用 config() 函数修改文件系统配置,并使用 Storage::fake() 方法创建一个假的 Storage 门面,以便在测试中使用。
在 tearDown() 方法中,我们需要清理测试目录,以避免占用磁盘空间。
7. Paratest 的高级用法
除了基本的用法之外,Paratest 还提供了一些高级功能,可以帮助你更好地管理和优化测试执行。
-
过滤测试: 可以使用
--filter选项过滤要执行的测试。例如,要只执行名称包含 "Example" 的测试,可以执行以下命令:paratest --filter Example -
指定测试套件: 可以使用
--testsuite选项指定要执行的测试套件。例如,要只执行 "Feature" 测试套件,可以执行以下命令:paratest --testsuite Feature -
生成代码覆盖率报告: 可以使用
--coverage-html或--coverage-clover选项生成代码覆盖率报告。例如,要生成 HTML 格式的代码覆盖率报告,可以执行以下命令:paratest --coverage-html ./coverage -
使用不同的 PHP 版本: 可以使用
--phpunit选项指定要使用的 PHPUnit 版本。例如,要使用 PHPUnit 9,可以执行以下命令:paratest --phpunit vendor/bin/phpunit9 -
处理大型测试套件: 对于非常大型的测试套件,Paratest 可能会因为内存不足而崩溃。这可以通过增加 PHP 的内存限制来解决。可以在
php.ini文件中修改memory_limit设置,或者在运行 Paratest 时使用-d选项指定内存限制。例如,要将内存限制设置为 2GB,可以执行以下命令:paratest -d memory_limit=2G
8. Paratest 与 CI/CD 集成
将 Paratest 集成到 CI/CD 流程中,可以显著缩短构建时间,提高开发效率。
以下是一个简单的 GitHub Actions 示例:
name: Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: dom, curl, libxml, mbstring, pdo, pdo_mysql, xdebug, zip
coverage: pcov
- name: Copy .env.example to .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Generate application key
run: php artisan key:generate
- name: Run Database Migrations
run: php artisan migrate
- name: Run Tests with Paratest
run: vendor/bin/paratest --coverage-clover coverage.xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
flags: unittests
name: codecov-paratest
在这个示例中,我们首先设置 PHP 环境,然后安装依赖,生成应用密钥,运行数据库迁移,最后使用 Paratest 运行测试,并将代码覆盖率报告上传到 Codecov。
9. 常见问题与解决方案
- 数据库连接错误: 确保每个测试进程使用独立的数据库连接,并正确配置数据库连接信息。
- 文件系统权限错误: 确保测试进程具有读写文件系统的权限。
- 内存不足错误: 增加 PHP 的内存限制。
- 测试结果不一致: 检查测试代码是否存在全局状态或共享资源,并进行隔离。
- Paratest 命令找不到: 确保 Paratest 已经正确安装,并且添加到系统的 PATH 环境变量中。
10. 总结:并行测试加速开发流程
通过使用 Paratest,可以将 PHPUnit 测试并行执行,显著缩短测试执行时间,提高开发效率。但是,在进行并行测试时,需要注意数据库隔离、文件系统隔离、避免全局状态等问题。只有正确配置和使用 Paratest,才能充分发挥其优势,加速大型项目的测试执行。
并行测试是提升效率的关键
使用 Paratest 可以大幅缩短测试时间,尤其是对于大型项目。正确配置数据库和文件系统隔离至关重要,避免测试之间的干扰。将 Paratest 集成到 CI/CD 流程中,可以更快地获得测试反馈,提升开发效率。