rasuvaeff/property-testing

Property-based testing plugin for Testo

Maintainers

Package info

github.com/rasuvaeff/property-testing

pkg:composer/rasuvaeff/property-testing

Transparency log

Statistics

Installs: 272

Dependents: 13

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.0 2026-07-02 19:22 UTC

This package is auto-updated.

Last update: 2026-07-02 19:56:39 UTC


README

Latest Stable Version Total Downloads Build Static analysis Psalm level PHP License

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-mbstring
  • ext-random
  • testo/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.

License

BSD-3-Clause