rasuvaeff/property-testing
Composer 安装命令:
composer require --dev rasuvaeff/property-testing
包简介
Property-based testing plugin for Testo
README 文档
README
Property-based testing for PHP 8.3+, built as a plugin for the Testo testing framework. Generate hundreds of random inputs per test, find the failing one, and shrink it to a minimal counterexample you can actually read.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model.
Requirements
- PHP 8.3+
ext-mbstringext-randomtesto/testo^0.10.25 || ^1.0
Installation
composer require --dev rasuvaeff/property-testing
No plugin registration is needed: the #[Property] attribute self-registers
with Testo through the framework's interceptor discovery.
Usage
Mark a test method with #[Property] and point it at a generators method that
maps each parameter name to a Gen factory.
The runner generates random arguments, runs the property runs times, and on
the first failure shrinks the counterexample to a minimal one.
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<string, \Rasuvaeff\PropertyTesting\ArbitraryInterface> */ 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), ]; } }
On failure, the counterexample is rendered into the test output:
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))
Reproduce the exact run by passing the reported seed back to the attribute:
#[Property(runs: 500, seed: 7382910, generators: 'delayGenerators')]
Why generators are in a separate method
PHP attribute arguments must be constant expressions, so #[Given('x', Gen::int())]
is not expressible. Instead name a method that returns
array<string, ArbitraryInterface> keyed by parameter name. When the generators
argument is omitted the runner falls back to a method named <testMethod>Generators.
Generators
| Factory | Produces | Shrinks |
|---|---|---|
Gen::int() |
IntArbitrary, PHP_INT_MIN..PHP_INT_MAX |
toward 0 |
Gen::intBetween($min, $max) |
IntArbitrary, [$min, $max] |
toward 0, clamped to range |
Gen::intPositive() |
IntArbitrary, 1..PHP_INT_MAX |
toward 1 |
Gen::float() |
FloatArbitrary, [0.0, 1.0) |
toward 0.0 |
Gen::floatBetween($min, $max) |
FloatArbitrary, [$min, $max] |
toward 0.0, clamped to range |
Gen::bool() |
BoolArbitrary, true / false |
true -> false |
Gen::string() |
StringArbitrary, Unicode, length 0..100 |
toward '', then by length, then each character toward a |
Gen::stringAscii() |
StringArbitrary, printable ASCII, length 0..100 |
toward '', then by length, then each character toward a |
Gen::stringOf($min, $max) |
StringArbitrary, Unicode, bounded length |
toward '', then by length, then each character toward a |
Gen::arrayOf($element) |
ArrayArbitrary, lists of $element, size 0..100 |
toward [], then by length, then each element |
Gen::nonEmptyArrayOf($element) |
ArrayArbitrary, non-empty lists |
by length (never below 1), then each element |
Gen::oneOf(...$values) |
OneOfArbitrary, one of the given values |
each distinct other value |
Gen::nullable($inner) |
NullableArbitrary, null or an $inner value |
prefers null |
Gen::map($inner, $fn) |
MappedArbitrary, $inner transformed by $fn |
no shrinking (mapping may not be invertible) |
Gen::filter($inner, $predicate) |
FilteredArbitrary, $inner values satisfying $predicate |
delegates, keeping predicate-satisfying candidates |
Gen::tuple(...$elements) |
TupleArbitrary, fixed-arity tuple, one value per element |
each position via its element, arity fixed |
Gen::frequency($pairs) |
FrequencyArbitrary, weighted choice over [weight, arbitrary] pairs |
delegates to the inner arbitraries |
Assume::that()
Discards the current run when a precondition does not hold. Discarded runs are
neither failures nor successful checks. Prefer it over Gen::filter() when the
rejection rate is low; when more than 90% of runs are discarded the runner warns
that the generators are likely misconfigured.
Assume::that($cap >= $baseSeconds);
Security
This package executes test methods via reflection (to read the #[Property]
attribute and invoke the generators method) and through Testo's pipeline. The
fallback Testo interceptor is PropertyInterceptor. It
performs no I/O, SQL, shell, or network operations itself. Random values are
generated with PHP's MT19937 engine seeded by the reported seed; do not rely on
them for cryptographic purposes.
Examples
See examples/ for runnable scripts.
| Script | Shows | Needs server? |
|---|---|---|
basic.php |
a property that holds, one that is falsified, and shrinking | No |
Development
No PHP/Composer on the host. Run commands in Docker via the composer:2 image:
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
Or with Make:
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check
make test-coverage and make mutation bootstrap pcov inside the
composer:2 container because the base image has no coverage driver.
License
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 0
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: BSD-3-Clause
- 更新时间: 2026-06-29