PHPUnit的并行测试配置:利用Paratest工具加速大型项目的测试执行时间

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 流程中,可以更快地获得测试反馈,提升开发效率。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注