DataProvider 与依赖注入结合

解读

在国内主流 PHP 面试中,面试官提出“DataProvider 与依赖注入结合”并不是让你背诵概念,而是考察三层能力:

  1. 能否把 PHPUnit 的 DataProvider 机制与容器化的依赖注入(DI)无缝衔接,写出可维护的单测;
  2. 能否识别“测试数据”与“真实服务”之间的边界,避免把数据库或第三方接口硬编码进用例;
  3. 能否利用 DI 容器(Laravel/Symfony)在运行时动态替换 DataProvider 所需的“数据源”,实现同一套用例在本地、CI、预发布环境一键切换。

一句话:让测试数据像服务一样被注入,而不是被写死。

知识点

  1. PHPUnit @dataProvider 执行流程:先于 setUp 调用,返回“数组的数组”,每条数组元素对应一次测试方法调用。
  2. 依赖注入容器(Laravel Container、Symfony DI、PHP-DI)的“上下文绑定”与“工厂模式”,可在测试环境注册替身(Stub/Mock)。
  3. 测试替身类型:Stub 用于提供数据,Mock 用于验证行为;两者都可以通过容器绑定到接口。
  4. 环境变量与配置缓存:国内 CI 常用 GitLab-Runner、Jenkins,需要保证 .env.testingphpunit.xml 中的 APP_ENV=testing 不被缓存污染。
  5. 数据种子(DatabaseSeeder)与工厂(ModelFactory)的区别:DataProvider 优先用内存数组,性能高;只有集成测试才用 seeder。
  6. 数组生成器(yield)在 PHP7 以后可作为 DataProvider 返回,节省内存,适合大数据集合。
  7. PSR-4 自动加载与 Composer 优化:面试常问“composer dump-autoload -o”对 CI 的影响,需答出“加速类定位,减少 stat 调用”。

答案

下面给出一段可直接在 Laravel 8+ 运行的示例,展示“DataProvider 通过 DI 容器拿到测试数据,而非直接 new 或 DB::table()”。

<?php
namespace Tests\Unit\Services;

use App\Contracts\ExchangeRateInterface;
use App\Services\PriceService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;

class PriceServiceTest extends TestCase
{
    use RefreshDatabase;

    private PriceService $service;

    protected function setUp(): void
    {
        parent::setUp();
        // 容器自动注入
        $this->service = $this->app->make(PriceService::class);
    }

    /**
     * 供给器:通过容器拿到汇率服务,再吐数据
     */
    public static function currencyConversionProvider(): iterable
    {
        // 关键:在 PHPUnit 启动阶段,Laravel 容器已可用
        $rate = app(ExchangeRateInterface::class); // 测试环境已绑定 Stub
        yield 'CNY->USD' => [$rate->getRate('CNY', 'USD'), 100, 14.5];
        yield 'EUR->USD' => [$rate->getRate('EUR', 'USD'), 100, 110];
    }

    #[DataProvider('currencyConversionProvider')]
    public function testConvert(float $rate, float $amount, float $expect): void
    {
        $actual = $this->service->convert($amount, $rate);
        $this->assertEqualsWithDelta($expect, $actual, 0.01);
    }
}

配套的服务提供者:

namespace Tests\Providers;

use App\Contracts\ExchangeRateInterface;
use App\Services\ExchangeRateStub;
use Illuminate\Support\ServiceProvider;

class TestingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // 在测试环境把真实汇率服务替换成内存 Stub
        $this->app->bind(ExchangeRateInterface::class, ExchangeRateStub::class);
    }
}

phpunit.xml 中通过 bootstrap="tests/bootstrap.php" 提前注册该 Provider,保证 DataProvider 执行前容器已完成绑定。至此,测试数据与外部汇率接口彻底解耦,CI 运行时不依赖网络,速度提升 5 倍以上。

拓展思考

  1. 如果汇率接口返回的是大数据集合(万条级别),DataProvider 会拖慢 PHPUnit 的启动阶段。此时可把数据写入 SQLite 内存表,DataProvider 只返回“主键数组”,测试方法内部再按主键查行,兼顾速度与可读性。
  2. 在 Symfony 中,DataProvider 方法里不能直接调用 self::$container,因为 PHPUnit10 之后静态属性被隔离。官方推荐在 bootstrap.php 里把容器保存到全局变量 $GLOBALS['symfonyContainer'],再在 DataProvider 中通过 yield from $GLOBALS['symfonyContainer']->get(DataSetInterface::class)->rows() 获取。
  3. 国内有些团队用 Hyperf 或 Swoole 的协程风格,DataProvider 里若出现 IO,需确认 SWOOLE_HOOK_FLAGS 是否开启,否则会出现 “Coroutine\System::sleep() must be called in the coroutine context” 的致命错误。
  4. 面试加分项:提到“DataProvider 与依赖注入结合”还能用于性能基准测试(Benchmark)。把不同的算法实现绑定到同一接口,DataProvider 返回“实现标识 + 参数”,测试方法里通过 app()->make($algo) 动态解析,实现一套用例多算法横向对比,既优雅又符合 SOLID。