rasuvaeff/property-testing

GitHub: rasuvaeff/property-testing

面向 PHP 8.3+ / Testo 框架的属性测试插件,通过自动生成大量随机输入并缩减至最小反例,帮助开发者发现传统用例难以覆盖的边界缺陷。

Stars: 0 | Forks: 0

# rasuvaeff/property-testing [![最新稳定版本](https://poser.pugx.org/rasuvaeff/property-testing/v)](https://packagist.org/packages/rasuvaeff/property-testing) [![总下载量](https://poser.pugx.org/rasuvaeff/property-testing/downloads)](https://packagist.org/packages/rasuvaeff/property-testing) [![构建](https://static.pigsec.cn/wp-content/uploads/repos/cas/05/052bc9eaea1744e860f382f618f1146be38e27dfd499bb29c253bcd1827fad60.svg)](https://github.com/rasuvaeff/property-testing/actions/workflows/build.yml) [![静态分析](https://static.pigsec.cn/wp-content/uploads/repos/cas/7a/7add566376a37a80d4b44e4bb0d8fc06395008f67cfea60957c3632e0114029e.svg)](https://github.com/rasuvaeff/property-testing/actions/workflows/static-analysis.yml) [![Psalm 等级](https://img.shields.io/badge/psalm-level_1-blue.svg)](https://github.com/rasuvaeff/property-testing/actions/workflows/static-analysis.yml) [![PHP](https://img.shields.io/packagist/dependency-v/rasuvaeff/property-testing/php)](https://packagist.org/packages/rasuvaeff/property-testing) [![许可证](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](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, 基于属性的测试, 测试工具, 测试框架插件, 随机测试