rasuvaeff / property-testing
Property-based testing plugin for Testo
Requires
- php: 8.3 - 8.5
- ext-mbstring: *
- ext-random: *
- testo/testo: ^0.10.25 || ^1.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33 || ^0.34
- maglnet/composer-require-checker: ^4.17
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- vimeo/psalm: ^6.16
This package is auto-updated.
Last update: 2026-07-02 19:56:39 UTC
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.
Since 2.0 shrinking is integrated: generate() returns a
Shrinkable — the value plus a lazy tree of smaller
candidates — so transformed generators (Gen::map(), Gen::flatMap()) shrink
through their source domain. Upgrading from 1.x? See UPGRADE.md.
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::dictOf($key, $value) |
DictionaryArbitrary, maps with keys from $key (int/string) and values from $value, size 0..100 |
toward [], then by size, then each value (keys fixed) |
Gen::record($shape) |
RecordArbitrary, fixed-shape map ['field' => $arb, ...] |
each field via its arbitrary, key set fixed |
Gen::elements($array) |
OneOfArbitrary, one value from an array (array form of oneOf) |
toward earlier-listed distinct values |
Gen::constant($value) |
ConstantArbitrary, always $value |
does not shrink |
Gen::char() |
StringArbitrary, a single printable ASCII character |
toward a |
Gen::uuid() |
UuidArbitrary, RFC 4122 v4 UUID strings |
does not shrink |
Gen::datetime($min, $max) |
DateTimeArbitrary, UTC DateTimeImmutable, timestamp in [$min, $max] |
toward the Unix epoch, clamped |
Gen::oneOf(...$values) |
OneOfArbitrary, one of the given values |
toward earlier-listed distinct values (put simpler values first) |
Gen::nullable($inner) |
NullableArbitrary, null or an $inner value |
prefers null, then the inner tree |
Gen::map($inner, $fn) |
MappedArbitrary, $inner transformed by $fn |
through the inner tree, re-applying $fn |
Gen::flatMap($inner, $fn) |
FlatMappedArbitrary, dependent generator returned by $fn($innerValue) |
source value first (dependent value regenerated), then the dependent tree |
Gen::filter($inner, $predicate) |
FilteredArbitrary, $inner values satisfying $predicate |
inner tree, pruning candidates that fail the predicate |
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 |
within the branch that generated the value |
Numeric generators (int*, float*) are boundary-biased: roughly one draw in
five returns an in-range edge value (0, ±1, min, max for ints; 0.0 or
min for floats), where bugs cluster, instead of a uniform one. Shrinking is
unaffected.
Dependent generators (flatMap)
When one input's domain depends on another — a list plus a valid index into it,
a size plus a payload of that size — Gen::flatMap() feeds each generated value
into a closure that returns the arbitrary for the final value. Unlike an
Assume::that() guard, no runs are discarded, and both levels shrink: the
source value shrinks (the dependent value is regenerated deterministically from
the run's seed), then the dependent value shrinks with the source held fixed.
/** @return array<string, ArbitraryInterface> */ 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()
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);
Bounding shrink work
By default shrinking runs until no smaller candidate still fails, re-running the
property once per accepted step. On expensive properties or very large inputs you
can cap the number of accepted shrink steps with maxShrinks:
#[Property(runs: 200, maxShrinks: 25)]
maxShrinks: null (the default) means no cap. maxShrinks: 0 disables shrinking
entirely and reports the original counterexample unchanged. The cap counts
accepted shrink steps, not test executions.
Writing your own arbitrary
Gen covers common cases, but any value space is reachable by implementing
ArbitraryInterface directly: generate(Random)
returns a Shrinkable — the drawn value plus a lazy tree
of smaller candidates, most aggressive first, each carrying its own subtree.
Draw randomness only through the injected Random (int(), float(),
bytes()) so seeded runs stay reproducible.
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); } }); } }
A custom arbitrary is used like any built-in: return it from the generators
method keyed by parameter name. Shrinkable::leaf($value) builds a terminal
node (no candidates); Shrinkable::of($value, $closure) attaches lazily
computed candidates; Shrinkable::map($fn) transforms a whole tree. Keep every
branch of the tree finite and never yield a candidate equal to its parent —
that is what guarantees shrinking terminates.
Environment overrides
Two environment variables tune runs without touching the attributes — useful in CI:
| Variable | Effect |
|---|---|
PROPERTY_RUNS |
Positive integer that overrides every property's run count (dial runs up in CI). |
PROPERTY_SEED |
Integer seed used for any property whose attribute omits seed (replay a whole suite). An explicit attribute seed still wins. |
Checking the distribution
A property can pass vacuously if its generators never reach the interesting
inputs. Classify records labels per run; after a fully passing property the
runner prints the share of runs that hit each label.
#[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)
A label recorded several times within one run still counts once for that run.
Sampling a generator
Gen::sample() eagerly generates values from any arbitrary for a fixed seed — a
quick way to eyeball what a generator produces (it returns values, not an
arbitrary).
Gen::sample(Gen::intBetween(1, 6), count: 5, seed: 42); // [3, 1, 6, 6, 2]
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 tree-based shrinking | No |
property_test.php |
canonical #[Property] usage as a real Testo test case |
No |
generators.php |
sample, boundary bias, uuid, datetime, dictOf, record, flatMap |
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.