4、数据供给器:使用@dataProvider注解、从CSV/JSON文件读取数据、与测试夹具结合

数据供给器,说白了就是帮我们「批量喂数据」的利器。

我刚开始写单元测试时,经常一个测试方法测一个场景。后来发现,很多逻辑其实只是输入不同,断言逻辑完全一样。比如测试一个「判断用户是否成年」的方法,你得测18岁、17岁、19岁、0岁、负数……如果每个写一个测试方法,代码会膨胀得很快。

这时候,数据供给器就派上用场了。

4.1 @dataProvider 注解:最基础的批量测试

PHPUnit 提供了 @dataProvider 注解,可以让你把一个测试方法的数据源抽出来,单独写一个「数据供给方法」。

核心思路:测试方法只写断言逻辑,数据由供给器方法返回一个数组(或迭代器)。

来看一个例子:

class AgeTest extends TestCase
{
    /**
     * @dataProvider ageProvider
     */
    public function testIsAdult($age, $expected)
    {
        $result = (new AgeChecker())->isAdult($age);
        $this->assertEquals($expected, $result);
    }

    public function ageProvider(): array
    {
        return [
            [18, true],
            [17, false],
            [19, true],
            [0, false],
            [-1, false],
            [150, true],  // 嗯,这里其实有边界问题,后面再说
        ];
    }
}

数据供给器返回的是一个二维数组。每个子数组对应一次测试调用,里面的元素会按顺序传给测试方法的参数。

我个人习惯把数据供给方法命名为 xxxProvider,这样一眼就能看出来它是给谁用的。你想想看,如果命名成 getDataForTest,时间久了你自己都可能忘了它对应哪个测试。

小技巧:数据供给器也可以返回 Iterator 对象。如果数据量特别大(比如上万条),用迭代器可以节省内存。

4.2 从 CSV 文件读取数据

当测试数据量变大,或者数据需要经常由非技术人员维护时,写在 PHP 数组里就不太合适了。这时候,CSV 文件是个好选择。

我曾经在一个项目中,需要测试一个「批量导入用户」的功能。测试数据有几十种组合,产品经理每周都会加新的边界情况。如果每次改 PHP 代码,既麻烦又容易出错。后来我把数据放到 CSV 里,产品经理自己就能维护。

实现起来很简单:

class UserImportTest extends TestCase
{
    /**
     * @dataProvider csvProvider
     */
    public function testImportValidation($name, $email, $age, $expectedError)
    {
        // 测试逻辑...
    }

    public function csvProvider(): array
    {
        $data = [];
        $handle = fopen(__DIR__ . '/test_data.csv', 'r');
        // 跳过表头
        fgetcsv($handle);
        while (($row = fgetcsv($handle)) !== false) {
            $data[] = $row;
        }
        fclose($handle);
        return $data;
    }
}

CSV 文件内容示例:

name,email,age,expected_error
张三,zhangsan@test.com,25,
李四,invalid-email,30,invalid_email
王五,wangwu@test.com,-5,negative_age
赵六,zhaoliu@test.com,17,under_age

注意:CSV 文件路径最好用 __DIR__ 拼接,不要用相对路径。否则在不同目录下运行测试时,文件可能找不到。我曾经因为这个坑排查了半天……

4.3 从 JSON 文件读取数据

如果数据结构比较复杂(比如嵌套数组、多层级),CSV 就不太够用了。JSON 是更好的选择。

我个人更偏爱 JSON,因为它的结构清晰,而且 PHP 解析起来非常方便。

class OrderTest extends TestCase
{
    /**
     * @dataProvider jsonProvider
     */
    public function testOrderTotal($items, $coupon, $expectedTotal)
    {
        // 测试逻辑...
    }

    public function jsonProvider(): array
    {
        $json = file_get_contents(__DIR__ . '/test_orders.json');
        $cases = json_decode($json, true);
        $data = [];
        foreach ($cases as $case) {
            $data[] = [
                $case['items'],
                $case['coupon'],
                $case['expected_total'],
            ];
        }
        return $data;
    }
}

JSON 文件示例:

[
    {
        "items": [{"name": "书", "price": 30, "qty": 2}],
        "coupon": null,
        "expected_total": 60
    },
    {
        "items": [{"name": "笔", "price": 5, "qty": 10}],
        "coupon": {"type": "percent", "value": 10},
        "expected_total": 45
    }
]

建议:JSON 文件里可以加一个 _description 字段来描述测试场景。虽然 PHPUnit 不会用它,但方便你和其他人理解每个测试用例的意图。

4.4 与测试夹具结合

数据供给器和 setUp() 方法(测试夹具)是可以共存的。但有个细节要注意:数据供给器在 setUp 之前执行

为什么会这样?因为 PHPUnit 需要先知道有多少组数据,才能决定要运行多少次测试。所以数据供给器不能依赖 setUp 里创建的对象。

来看一个实际场景:

class DatabaseTest extends TestCase
{
    private $db;

    protected function setUp(): void
    {
        $this->db = new DatabaseConnection('test_config');
    }

    /**
     * @dataProvider userProvider
     */
    public function testSaveUser($userData, $expectedId)
    {
        // 这里可以安全使用 $this->db
        $result = $this->db->save($userData);
        $this->assertEquals($expectedId, $result);
    }

    public function userProvider(): array
    {
        // 注意:这里不能使用 $this->db
        // 因为 setUp 还没执行
        return [
            [['name' => 'Alice', 'age' => 25], 1],
            [['name' => 'Bob', 'age' => 30], 2],
        ];
    }
}

避坑指南:我曾经在数据供给器里尝试调用 $this->db->query() 来动态生成数据,结果报错说数据库连接不存在。后来才意识到,供给器执行时 setUp 还没跑完。

那如果确实需要在供给器里访问数据库怎么办?有两个思路:

  • 在供给器里自己创建连接(不推荐,会破坏测试隔离性)
  • 把数据提前准备好,比如写到 JSON 文件里,供给器只负责读取

我个人推荐第二种。测试数据应该是静态的、可重复的。动态生成数据会让测试变得不可预测。

4.5 实战建议:什么时候用什么方式?

场景 推荐方式 理由
测试数据少(<10组) @dataProvider 数组 简单直接,无需额外文件
数据由非技术人员维护 CSV 文件 Excel 可以直接编辑,门槛低
数据结构复杂 JSON 文件 支持嵌套,可读性好
数据量极大(万级以上) 迭代器 + CSV/JSON 节省内存,避免 PHP 内存溢出

嗯,最后说一句:数据供给器不是银弹。如果一个测试方法需要太多组数据,可能说明被测逻辑太复杂了。这时候不妨想想——是不是该拆分了?