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