rasuvaeff/property-testing
GitHub: rasuvaeff/property-testing
面向 PHP 8.3+ / Testo 框架的属性测试插件,通过自动生成大量随机输入并缩减至最小反例,帮助开发者发现传统用例难以覆盖的边界缺陷。
Stars: 0 | Forks: 0
# rasuvaeff/property-testing
[](https://packagist.org/packages/rasuvaeff/property-testing)
[](https://packagist.org/packages/rasuvaeff/property-testing)
[](https://github.com/rasuvaeff/property-testing/actions/workflows/build.yml)
[](https://github.com/rasuvaeff/property-testing/actions/workflows/static-analysis.yml)
[](https://github.com/rasuvaeff/property-testing/actions/workflows/static-analysis.yml)
[](https://packagist.org/packages/rasuvaeff/property-testing)
[](LICENSE.md)
适用于 PHP 8.3+ 的基于属性的测试,作为
[Testo](https://github.com/php-testo/testo) 测试框架的插件构建。为每个测试生成数百个随机输入,找出失败的那个,并将其缩减为您真正可以阅读的最小反例。
自 2.0 版起,缩减功能已**集成**:`generate()` 返回一个
[`Shrinkable`](src/Shrinkable.php) —— 该值加上一个由更小候选值组成的惰性树 —— 因此转换后的生成器(`Gen::map()`、`Gen::flatMap()`)通过其源域进行缩减。从 1.x 升级?请参阅 [UPGRADE.md](UPGRADE.md)。
## 环境要求
- PHP 8.3+
- `ext-mbstring`
- `ext-random`
- [`testo/testo`](https://packagist.org/packages/testo/testo) `^0.10.25 || ^1.0`
## 安装
```
composer require --dev rasuvaeff/property-testing
```
无需注册插件:`#[Property]` attribute 会通过框架的拦截器发现机制在 Testo 中自动注册。
## 用法
使用 `#[Property]` 标记测试方法,并将其指向一个生成器方法,该方法将每个参数名称映射到对应的 `Gen` 工厂。
运行器会生成随机参数,将属性运行 `runs` 次,并在首次失败时将反例缩减为最小反例。
```
use Rasuvaeff\PropertyTesting\Assume;
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\Property;
use Testo\Assert;
use Testo\Test;
#[Test]
final class RetryPolicyPropertyTest
{
#[Property(runs: 500, generators: 'delayGenerators')]
public function delayNeverExceedsCap(int $maxAttempts, int $baseSeconds, int $cap, int $attempts): void
{
Assume::that($cap >= $baseSeconds);
$policy = WebhookRetryPolicy::exponential($maxAttempts, $baseSeconds, $cap);
Assert::true($policy->nextDelaySeconds($attempts) <= $cap);
}
/** @return array */
private function delayGenerators(): array
{
return [
'maxAttempts' => Gen::intBetween(1, 50),
'baseSeconds' => Gen::intBetween(1, 300),
'cap' => Gen::intBetween(1, 86400),
'attempts' => Gen::intBetween(1, 100),
];
}
}
```
失败时,反例将呈现到测试输出中:
```
Property falsified after 246 successful run(s); seed=7382910
Original: maxAttempts=17, baseSeconds=91, cap=847, attempts=23
Shrunk: maxAttempts=1, baseSeconds=848, cap=847, attempts=1 (12 shrink step(s))
```
通过将报告的 seed 传回 attribute 来重现确切的运行过程:
```
#[Property(runs: 500, seed: 7382910, generators: 'delayGenerators')]
```
### 为什么生成器要放在单独的方法中
PHP attribute 参数必须是常量表达式,因此 `#[Given('x', Gen::int())]`
是无法表达的。取而代之的是,指定一个返回 `array` 的方法,并按参数名称作为键。当省略 `generators` 参数时,运行器会默认回退到名为 `Generators` 的方法。
### 生成器
| 工厂 | 生成内容 | 缩减 |
|---|---|---|
| `Gen::int()` | `IntArbitrary`,`PHP_INT_MIN..PHP_INT_MAX` | 趋向于 `0` |
| `Gen::intBetween($min, $max)` | `IntArbitrary`,`[$min, $max]` | 趋向于 `0`,限制在范围内 |
| `Gen::intPositive()` | `IntArbitrary`,`1..PHP_INT_MAX` | 趋向于 `1` |
| `Gen::float()` | `FloatArbitrary`,`[0.0, 1.0)` | 趋向于 `0.0` |
| `Gen::floatBetween($min, $max)` | `FloatArbitrary`,`[$min, $max]` | 趋向于 `0.0`,限制在范围内 |
| `Gen::bool()` | `BoolArbitrary`,`true` / `false` | `true` -> `false` |
| `Gen::string()` | `StringArbitrary`,Unicode,长度 0..100 | 趋向于 `''`,然后按长度,接着每个字符趋向于 `a` |
| `Gen::stringAscii()` | `StringArbitrary`,可打印 ASCII,长度 0..100 | 趋向于 `''`,然后按长度,接着每个字符趋向于 `a` |
| `Gen::stringOf($min, $max)` | `StringArbitrary`,Unicode,有界长度 | 趋向于 `''`,然后按长度,接着每个字符趋向于 `a` |
| `Gen::stringFrom($alphabet, $min, $max)` | `CharsetStringArbitrary`,来自固定字母表的字符(支持多字节) | 趋向于 `''`,然后按长度,接着每个字符趋向于字母表的第一个字符 |
| `Gen::bytes($min, $max)` | `BytesArbitrary`,原始字节字符串(字节 0..255) | 趋向于 `''`,然后按长度,接着每个字节趋向于 `"\x00"` |
| `Gen::arrayOf($element, $min, $max)` | `ArrayArbitrary`,`$element` 的列表,默认大小为 0..100 | 趋向于 `[]`,然后按长度,接着每个元素 |
| `Gen::nonEmptyArrayOf($element, $max)` | `ArrayArbitrary`,非空列表 | 按长度(绝不低于 1),接着每个元素 |
| `Gen::uniqueArrayOf($element, $min, $max)` | `UniqueArrayArbitrary`,包含两两不同元素的列表 | 类似于 `arrayOf`,但会跳过与现有元素冲突的候选元素 |
| `Gen::dictOf($key, $value, $min, $max)` | `DictionaryArbitrary`,键来自 `$key`(整数/字符串)、值来自 `$value` 的 map,默认大小为 0..100 | 趋向于 `[]`,然后按大小,接着每个值(键固定) |
| `Gen::record($shape)` | `RecordArbitrary`,固定形状的 map `['field' => $arb, ...]` | 通过其 arbitrary 处理每个字段,键集合固定 |
| `Gen::elements($array)` | `OneOfArbitrary`,从数组中取一个值(`oneOf` 的数组形式) | 趋向于列表中较前的不重复值 |
| `Gen::enum(SomeEnum::class)` | 枚举 case 上的 `OneOfArbitrary` | 趋向于声明中较前的 case(请先声明较简单的 case) |
| `Gen::constant($value)` | `ConstantArbitrary`,始终为 `$value` | 不进行缩减 |
| `Gen::char()` | `StringArbitrary`,单个可打印 ASCII 字符 | 趋向于 `a` |
| `Gen::uuid()` | `UuidArbitrary`,RFC 4122 v4 UUID 字符串 | 不进行缩减 |
| `Gen::datetime($min, $max)` | `DateTimeArbitrary`,UTC `DateTimeImmutable`,时间戳在 `[$min, $max]` 内 | 趋向于 Unix 纪元,有界 |
| `Gen::floatSpecial()` | `NAN`、`±INF`、`-0.0` 及浮点表示边界上的 `OneOfArbitrary` | 趋向于列表中较前的特殊值 |
| `Gen::intRange($min, $max)` | `FlatMappedArbitrary`,满足 `lo <= hi` 的有序对 `[lo, hi]` | 两个边界同时缩减,始终保持顺序 |
| `Gen::recursive($leaf, $wrap, $maxDepth)` | 有界递归结构:`$wrap` 提升上一层级的 arbitrary | 在生成该值的分支内 |
| `Gen::oneOf(...$values)` | `OneOfArbitrary`,给定值中的一个 | 趋向于列表中较前的不重复值(将较简单的值放在前面) |
| `Gen::nullable($inner)` | `NullableArbitrary`,`null` 或一个 `$inner` 值 | 优先 `null`,然后是内部树 |
| `Gen::map($inner, $fn)` | `MappedArbitrary`,由 `$fn` 转换的 `$inner` | 贯穿内部树,重新应用 `$fn` |
| `Gen::flatMap($inner, $fn)` | `FlatMappedArbitrary`,由 `$fn($innerValue)` 返回的依赖生成器 | 源值优先(依赖值会重新生成),然后是依赖树 |
| `Gen::filter($inner, $predicate)` | `FilteredArbitrary`,满足 `$predicate` 的 `$inner` 值 | 内部树,修剪不满足断言的候选值 |
| `Gen::tuple(...$elements)` | `TupleArbitrary`,固定参数数量的元组,每个元素对应一个值 | 通过其元素处理每个位置,参数数量固定 |
| `Gen::frequency($pairs)` | `FrequencyArbitrary`,在 `[weight, arbitrary]` 对上进行加权选择 | 在生成该值的分支内 |
数值型生成器(`int*`、`float*`)具有**边界偏好**:大约每五次抽取就会返回一个范围内的边界值(整数返回 `0`、`±1`、`min`、`max`;浮点数返回 `0.0` 或 `min`),这些是 Bug 集中的地方,而不是均匀分布。缩减不受此影响。
### 依赖生成器 (`flatMap`)
当一个输入的定义域依赖于另一个输入时 —— 例如一个列表加上指向其中的有效索引,或者一个大小加上该大小的 payload —— `Gen::flatMap()` 会将每个生成的值传递给一个闭包,该闭包返回最终值的 arbitrary。与 `Assume::that()` 守卫不同,这种方式不会丢弃任何运行,且两个层级都会缩减:源值缩减(依赖值根据运行的 seed 确定性地重新生成),然后依赖值在源值固定的前提下进行缩减。
```
/** @return array */
private function sliceGenerators(): array
{
return ['pair' => Gen::flatMap(
Gen::nonEmptyArrayOf(Gen::int()),
static fn(array $items): ArbitraryInterface => Gen::tuple(
Gen::constant($items),
Gen::intBetween(0, count($items) - 1), // always a valid index
),
)];
}
```
### `Assume::that()`
当先决条件不满足时丢弃当前运行。丢弃的运行既不是失败,也不是成功的检查。当拒绝率较低时,优先使用它而不是 `Gen::filter()`;当超过 90% 的运行被丢弃时,运行器会发出警告,提示生成器可能配置错误。
```
Assume::that($cap >= $baseSeconds);
```
### 限制缩减工作量
默认情况下,缩减会一直运行直到没有更小的候选值仍然失败为止,每接受一步缩减都会重新运行一次属性。对于昂贵的属性或非常大的输入,您可以使用 `maxShrinks` 来限制接受的缩减步骤数量:
```
#[Property(runs: 200, maxShrinks: 25)]
```
`maxShrinks: null`(默认值)表示没有限制。`maxShrinks: 0` 完全禁用缩减,并原样报告原始反例。该限制计算的是*已接受的*缩减步骤,而不是测试执行次数。
### 编写自定义 arbitrary
`Gen` 涵盖了常见情况,但通过直接实现 [`ArbitraryInterface`](src/ArbitraryInterface.php) 可以触及任何值空间:`generate(Random)` 返回一个 [`Shrinkable`](src/Shrinkable.php) —— 抽取的值加上一个由更小候选值组成的惰性树,最积极的优先,每个都带有自己的子树。仅通过注入的 `Random`(`int()`、`float()`、`bytes()`)获取随机性,以确保带 seed 的运行保持可重现性。
```
use Rasuvaeff\PropertyTesting\ArbitraryInterface;
use Rasuvaeff\PropertyTesting\Random;
use Rasuvaeff\PropertyTesting\Shrinkable;
/**
* Even integers in [0, $max], shrinking toward 0 in even steps.
*/
final readonly class EvenArbitrary implements ArbitraryInterface
{
public function __construct(private int $max = 1000) {}
#[\Override]
public function generate(Random $random): Shrinkable
{
return $this->tree($random->int(0, intdiv($this->max, 2)) * 2);
}
private function tree(int $value): Shrinkable
{
return Shrinkable::of($value, function () use ($value): \Generator {
if ($value === 0) {
return;
}
yield $this->tree(0);
$half = intdiv($value, 4) * 2; // stay even
if ($half !== 0 && $half !== $value) {
yield $this->tree($half);
}
});
}
}
```
自定义 arbitrary 的使用方式与内置的一样:在生成器方法中按参数名称作为键返回它。`Shrinkable::leaf($value)` 构建一个终端节点(无候选值);`Shrinkable::of($value, $closure)` 附加惰性计算的候选值;`Shrinkable::map($fn)` 转换整棵树。保持树的每个分支有限,并且永远不要生成与其父节点相等的候选值 —— 这正是保证缩减能够终止的条件。
### 环境变量覆盖
有两个环境变量可以在不触及 attribute 的情况下调整运行 —— 这在 CI 中非常有用:
| 变量 | 效果 |
|---|---|
| `PROPERTY_RUNS` | 覆盖每个属性运行次数的正整数(在 CI 中调高运行次数)。 |
| `PROPERTY_SEED` | 用于任何其 attribute 省略了 `seed` 的属性的整数 seed(重放整个测试套件)。显式的 attribute `seed` 仍然优先。 |
| `PROPERTY_VERBOSE` | 除 `''`/`0` 以外的任何值都会记录每次运行生成的参数 —— 准确查看重放的 seed 向属性输入了什么内容。 |
### 检查分布情况
如果生成器永远无法触及有趣的输入,属性可能会空洞地通过。`Classify` 会记录每次运行的标签;在属性完全通过后,运行器会打印命中每个标签的运行比例。
```
#[Property(runs: 500)]
public function holds(int $n): void
{
Classify::when($n === 0, 'zero');
Classify::label($n % 2 === 0 ? 'even' : 'odd');
// ... assertions ...
}
// Property "holds" distribution: odd 51% (255/500), even 49% (245/500), zero 1% (3/500)
```
在一次运行中多次记录的标签在该次运行中仍然只计算一次。
### 强制执行分布
`Classify::cover()` 将打印的提示升级为硬性要求:该标签必须出现在至少给定比例的成功运行中,否则属性会**失败**并抛出 `CoverageViolationException` —— 即使每次运行都通过了。使用确保在 CI 中不可能出现空洞的通过。
```
#[Property(runs: 500)]
public function holds(int $n): void
{
Classify::cover($n % 2 === 0, 'even', 30.0); // fail if < 30% of runs are even
// ... assertions ...
}
```
丢弃的运行(`Assume::that()`)会从分母中排除。如果一个属性的所有运行都被丢弃,则其覆盖率要求将彻底失败。
### 抽样生成器
`Gen::sample()` 会为固定的 seed 从任意 arbitrary 中预先生成值 —— 这是一种直观查看生成器生成内容的快捷方式(它返回的是值,而不是 arbitrary)。
```
Gen::sample(Gen::intBetween(1, 6), count: 5, seed: 42); // [3, 1, 6, 6, 2]
```
`Gen::sampleShrinks()` 对缩减树执行相同的操作:它生成一个值并列出其第一层直接缩减的候选值 —— 这是检查自定义 arbitrary 是否按预期缩减的最快方法。
```
Gen::sampleShrinks(Gen::intBetween(0, 100), seed: 1);
// ['value' => 87, 'shrinks' => [0, 44, 66, 77, 82, 85, 86]]
```
### 实用方案
无丢弃的依赖值 —— 构建,而不是过滤:
```
// A size and a payload of exactly that size.
Gen::flatMap(Gen::intBetween(1, 32), static fn(int $size): ArbitraryInterface
=> Gen::tuple(Gen::constant($size), Gen::bytes($size, $size)));
// An ordered interval: Gen::intRange(0, 1440) yields [lo, hi] with lo <= hi.
// Domain strings from an alphabet instead of filtering Unicode.
Gen::stringFrom('abcdefghijklmnopqrstuvwxyz0123456789-', 1, 63); // hostname label
```
有界递归数据:
```
use Rasuvaeff\PropertyTesting\Arbitrary\ArrayArbitrary;
// JSON-ish scalars nested in small arrays, at most 3 levels deep.
Gen::recursive(
Gen::oneOf(null, true, false, 0, 1, 'a'),
static fn(ArbitraryInterface $inner): ArbitraryInterface => new ArrayArbitrary($inner, 0, 3),
maxDepth: 3,
);
```
保持分支扇出较小(有界数组大小):宽度会在每个嵌套层级上相乘。
### 有状态 / 基于模型的测试
有些 Bug 只会在一系列操作中出现 —— 一个在 N 次递增后溢出的计数器,一个返回过期数据的缓存,一个丢失顺序的栈。基于模型的测试会生成随机的命令序列,针对真实系统运行每个命令,同时在一个简化的模型中对其进行镜像,并在失败时**将序列缩减**为仍然会导致破坏的最短序列。
实现 [`Command`](src/StateMachine/Command.php) —— 四个近乎纯粹的职责加上一个标签:
| 方法 | 用途 |
|---|---|
| `preCondition(mixed $model): bool` | 当前模型状态下是否可以运行此命令?控制生成过程,并在重放时决定命令是运行还是被跳过。 |
| `nextState(mixed $model): mixed` | 模型的预期下一个状态(纯函数;返回一个新模型,从不修改原模型)。 |
| `run(mixed $model, mixed $system): mixed` | 针对被测系统执行;返回观察到的结果。 |
| `postCondition(mixed $model, mixed $result): bool` | 根据前置状态模型检查结果。返回 `false`(或抛出异常)以证伪。 |
| `__toString(): string` | 在反例追踪中使用的标签。 |
`Gen::commands($initialModel, $commandGenerators)` 构建有效的序列(每一步都会追加一个满足前置条件的命令,然后推进模型),而 `StateMachine::check()` 会针对全新的系统驱动生成的序列:
```
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\Property;
use Rasuvaeff\PropertyTesting\StateMachine\CommandSequence;
use Rasuvaeff\PropertyTesting\StateMachine\StateMachine;
use Testo\Test;
#[Test]
final class StackModelTest
{
#[Property(runs: 200)]
public function stackBehavesLikeItsModel(CommandSequence $sequence): void
{
StateMachine::check($sequence, static fn(): Stack => new Stack());
}
/** @return array */
private function stackBehavesLikeItsModelGenerators(): array
{
return ['sequence' => Gen::commands([], [
Gen::map(Gen::intBetween(0, 99), static fn(int $v): Command => new Push($v)),
Gen::constant(new Pop()),
])];
}
}
```
缩减会移除整块命令(一直缩减到只剩一个命令,从而隔离出中间失败的步骤),然后通过各自的树简化每个命令的参数。由于运行器会重新检查每个前置条件,并跳过任何因丢弃步骤而失效的命令,因此每个缩减后的序列都能保持合理性。反例会呈现为可读的追踪记录,失败的后置条件会抛出 [`PostconditionViolation`](src/StateMachine/PostconditionViolation.php),并指明具体的步骤:
```
Property falsified after 7 successful run(s); seed=42
Original: sequence=[Push(3), Pop(), Push(5), Push(1), Pop(), Pop()]
Shrunk: sequence=[Push(0), Push(1), Pop()] (9 shrink step(s))
Failure: Postcondition failed at step 3 for command Pop(); sequence: [Push(0), Push(1), Pop()]
```
有关完整的栈示例,请参阅 [`examples/state_machine.php`](examples/state_machine.php)。
## 安全性
该软件包通过反射(读取 `#[Property]` attribute 并调用生成器方法)以及通过 Testo 的流水线来执行测试方法。后备的 Testo 拦截器是 `PropertyInterceptor`。它本身不执行任何 I/O、SQL、shell 或网络操作。随机值是使用由报告的 seed 播种的 PHP MT19937 引擎生成的;请勿将其用于加密目的。
## 示例
请参阅 [examples/](examples/) 获取可运行的脚本。
| 脚本 | 展示内容 | 需要服务器? |
|---|---|---|
| `basic.php` | 一个成立的属性、一个被证伪的属性,以及基于树的缩减 | 否 |
| `property_test.php` | 作为真实 Testo 测试用例的典型 `#[Property]` 用法 | 否 |
| `generators.php` | `sample`、边界偏好、`uuid`、`datetime`、`dictOf`、`record`、`flatMap` | 否 |
| `state_machine.php` | 有状态 / 基于模型的测试:`Command`、`Gen::commands()`、`StateMachine::check()` | 否 |
## 开发
宿主机上无需 PHP/Composer。通过 `composer:2` 镜像在 Docker 中运行命令:
```
docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app composer:2 composer build
docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix
docker run --rm -v "$PWD":/app -w /app composer:2 composer test
docker run --rm -v "$PWD":/app -w /app composer:2 composer release-check
```
或者使用 Make:
```
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check
```
`make test-coverage` 和 `make mutation` 会在 `composer:2` 容器内部引导 `pcov`,因为基础镜像没有覆盖率驱动程序。
## 许可证
[BSD-3-Clause](LICENSE.md)
标签:ffuf, OpenVAS, PHP, 基于属性的测试, 测试工具, 测试框架插件, 随机测试