Search by

rasuvaeff / property-testing-testo

rasuvaeff

Testo adapter for the property-testing engine: the #[Property] attribute, drop-in for rasuvaeff/property-testing

Package info

github.com/rasuvaeff/property-testing-testo

pkg:composer/rasuvaeff/property-testing-testo

Statistics

Installs: 8 771

Dependents: 44

Suggesters: 0

Stars: 1

Open Issues: 1

v1.0.0 2026-09-20 09:04 UTC

README

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

Русская версия

Testo adapter for the property-testing engine: the #[Property] attribute, reflection conventions, and environment overrides — a drop-in replacement for the frozen rasuvaeff/property-testing 2.x. 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.

Part of the property-testing family

Package Use it when
rasuvaeff/property-testing-core You drive the engine yourself: a custom harness, CI guard, CLI checker, or another framework adapter
rasuvaeff/property-testing-testo (this package) You test with Testo — the classic #[Property] attribute
rasuvaeff/property-testing-phpunit You test with PHPUnit — a PropertyTesting trait with a fluent forAll()->check() API

Migrating from rasuvaeff/property-testing 2.x

The frozen rasuvaeff/property-testing package is superseded by this adapter. Migration is one Composer command — your PHP code does not change:

composer remove --dev rasuvaeff/property-testing
composer require --dev rasuvaeff/property-testing-testo

Everything is preserved:

  • the FQCN of every public class — Rasuvaeff\PropertyTesting\Property, Gen, ArbitraryInterface, Assume, Classify, the exceptions, the state machine: no import changes;
  • the <method>Generators() / <method>Examples() conventions;
  • the PROPERTY_RUNS / PROPERTY_SEED / PROPERTY_VERBOSE / PROPERTY_DB environment variables;
  • the counterexample message format;
  • a regression corpus written by 2.8 (PROPERTY_DB) is read as-is;
  • seed determinism: a seed recorded under 2.8 reproduces the same inputs.

The engine now lives in rasuvaeff/property-testing-core (pulled in automatically), which conflicts with the old package — Composer will refuse a mixed installation rather than let two copies of the namespace collide.

Requirements

Installation

composer require --dev rasuvaeff/property-testing-testo

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)]
    public function delayNeverExceedsCap(int $baseSeconds, int $cap, int $attempts): void
    {
        Assume::that($cap >= $baseSeconds);

        $policy = RetryPolicy::exponential($baseSeconds, $cap);

        Assert::true($policy->nextDelaySeconds($attempts) <= $cap);
    }

    /** @return array<string, \Rasuvaeff\PropertyTesting\ArbitraryInterface> */
    public static function delayNeverExceedsCapGenerators(): array
    {
        return [
            'baseSeconds' => Gen::intBetween(1, 300),
            'cap' => Gen::intBetween(1, 86_400),
            '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: baseSeconds=91, cap=847, attempts=23
  Shrunk:   baseSeconds=848, cap=847, attempts=1 (12 shrink step(s), 41 trial(s))
  Changed:  baseSeconds=91 -> 848, attempts=23 -> 1

Reproduce the exact run by passing the reported seed back to the attribute: #[Property(runs: 500, seed: 7382910)]. The message also ends with a Path: line — the shrink steps that were accepted — and passing that back beside the seed (#[Property(seed: 7382910, path: 'attempts:1/attempts:3')], or PROPERTY_SEED=… PROPERTY_PATH=…) follows the descent instead of searching for it again. The excerpt above is trimmed; a real run prints Failure: and Path: too.

Conventions

PHP attribute arguments must be constant expressions, so generators cannot be passed inline. Name a method returning array<string, ArbitraryInterface> keyed by parameter name; when the generators argument is omitted the adapter falls back to <testMethod>Generators. The same pattern applies to fixed examples: <testMethod>Examples (or #[Property(examples: 'method')]) returns positional argument tuples that run before the random inputs and are never shrunk.

Declare generators and examples methods public static (public if the body needs $this): their only call site is this adapter's reflection, so Rector's dead-code set would delete private ones.

What the adapter does and does not combine with:

  • Lifecycle hooks run on every property run. The interceptor sits inside Testo's lifecycle interceptor, so #[BeforeTest]/#[AfterTest] execute once per generated input, not once per test (PHPUnit's setUp runs once). A hook that throws is that run's failure and is shrunk like any other.
  • A data provider cannot be combined with #[Property] — the generators supply the arguments — and #[Property] on a function-based case is refused: both are reported as an error of the test with a message, as is any other misconfiguration (a missing generators method, a bad PROPERTY_RUNS, an unknown phase, runs: 0, a provider key that is not a parameter of the property). The attribute itself validates nothing: Testo instantiates it before the interceptor runs, so the interceptor is the one place that can name the property in the message.
  • #[ExpectException] cannot be combined with #[Property] and is refused as an error of the test: the expectation interceptor runs outside this one and observes the property's aggregate failure — a PropertyViolationException, which is a RuntimeException — so #[ExpectException(\RuntimeException::class)] would be satisfied by any falsification, a failed assertion included. State the expectation per run with throws: instead — not with Expect::exception() in the body either: it registers an expectation the same outer interceptor judges against the aggregate, and no guard can see it.
  • A SkipTest thrown from the body or a hook skips the run; when every run skipped, the property is reported as a skipped test. Partly skipped runs spend a budget of their own, separate from maxDiscards: a skip is not a discard, and when that budget runs out the message names the environment rather than advising narrower generators. Unlike an Assume::that() discard, a skip says nothing about the input, so a recorded regression whose replay only skipped stays in the corpus instead of being pruned.

Expected exceptions (throws:)

A body that throws never reaches #[ExpectException]: Testo's terminal handler turns the throw into that run's failed result before any outer interceptor sees it. throws: states the expectation where it is checked:

#[Property(runs: 200, throws: ImageUploadException::class)]
public function anImageNarrowerThanTheProfileIsRejected(int $width, int $minWidth): void
{
    $this->validator->validate(imageWithWidth($width), profileWithMinWidth($minWidth));
}

Semantics, per run:

  • Throws the class (a subclass matches) — the run passes. The matching throw is recorded as an assertion, the way Testo records its own fulfilled expectation, so a body that asserts nothing else is not reported as risky.
  • Returns normally — the run fails with Expected <class> to be thrown, but it was not, and the input shrinks like any other counterexample.
  • Throws another class — that throw is the failure, exactly as it would be without throws:.
  • A failed assertion is never the expected throw. Testo's assertion failures extend \LogicException, so throws: naming a class they are an instance of — \LogicException, \Exception, \Throwable — is refused when the property is set up (a failed assertion is an instance of); name the exception the body throws. A failing Assert inside the body fails the run whatever class was expected.
  • A SkipTest from the body or a hook still skips the run, and an Assume::that() discard still discards it: the environment's verdict about the run is never a pass earned by throwing.

A class that is not a Throwable is a misconfiguration, reported as the test's error. The same knob is PropertyCheck::throws() in the PHPUnit adapter.

Callable providers

generators and examples also accept a callable. Strings are resolved as a method on the property test class first; only when no such method exists are they treated as external callable names. Non-string callables are stored and invoked when the property is resolved, so an invokable provider can be written directly in a PHP 8.3 attribute:

final readonly class DelayGenerators
{
    /** @return array<string, \Rasuvaeff\PropertyTesting\ArbitraryInterface> */
    public function __invoke(): array
    {
        return [
            'base' => Gen::intBetween(1, 300),
            'cap' => Gen::intBetween(1, 86_400),
        ];
    }
}

#[Property(generators: new DelayGenerators())]
public function delayNeverExceedsCap(int $base, int $cap): void
{
}

Reusable static providers can use either [Provider::class, 'method'] or 'Provider::method'. PHP 8.5 additionally allows an inline static function (): array { ... } or a first-class callable such as Provider::method(...) in the attribute. A method on the test class named like a global function (for example range) still wins over that global function.

The flip side of string resolution: a misspelled method name that happens to match a global function ('count', 'range') is invoked as that function — typically failing with its own ArgumentCountError from the zero-argument call, or, when the function takes no arguments, with the result validation rejecting its return. A typo matching nothing fails immediately with neither a method on … nor a callable.

Auto-derived generators (auto: true)

When the parameters are fully described by their types, the provider method can go away entirely: auto: true derives a generator for every parameter from the property's own signature via Gen::forParameters() — the @param psalm type when there is one (int<1, 300>, non-empty-string, list<T>, 'a'|'b'), the native type otherwise:

/**
 * @param int<1, 300> $base
 * @param int<1, 86400> $cap
 */
#[Property(auto: true)]
public function delayNeverExceedsCap(int $base, int $cap): void
{
}

The provider — explicit or the <testMethod>Generators convention — becomes the overrides and may be partial: the parameters it names are taken as given, the rest are derived. That is the escape hatch for domains no psalm type can express (a float range, a dependent pair built with Gen::flatMap()):

/** @param int<1, 40> $attempt */
#[Property(generators: 'provide', auto: true)]
public function delayIsMonotonic(float $multiplier, int $attempt): void { /* … */ }

/** @return array<string, ArbitraryInterface> */
public static function provide(): array
{
    return ['multiplier' => Gen::floatBetween(1.0, 4.0)];   // the rest is derived
}

Rules worth knowing:

  • Strictly opt-in. auto defaults to false and will never become the default: a bare int or float derives its full native domain, and only the property's author knows whether that is the intended one. Annotate or override anything narrower.
  • A type the deriver cannot read (a bare array, mixed, an untyped or variadic parameter) fails with an error naming the method and the parameter — never a silently widened guess.
  • A provider key that is not a parameter of the property is an error, with or without auto: ignored, a typoed entry leaves its parameter without a generator, and under auto merge semantics would silently replace it with a signature-derived one. A provider shared by two properties of different arity is therefore refused — give each property its own.
  • A full provider plus auto: true is legal — auto derives nothing; that is the transitional state while a test migrates.
  • There is deliberately no PROPERTY_AUTO environment variable: the environment dials the suite (runs, phases), while auto changes what one property's arguments mean — attribute territory.

Attribute parameters

Parameter Meaning
runs Successful checks to complete (default 100). Discarded runs do not count
seed Pins the random phase for reproduction. Also disables corpus replay for this property — the pinned run wins
generators Method name or callable(): array<string, ArbitraryInterface>; default <testMethod>Generators
examples Method name or callable(): iterable<array<mixed>>; default <testMethod>Examples
maxShrinks Cap on accepted shrink steps; 0 disables shrinking
maxDiscards Cap for the discard budget and the skip budget. Left unset the two differ: runs * 10 for discards, runs for environmental skips
timeoutMs Wall-clock deadline for a single run — exceeding it fails the property with DeadlineExceededException
budgetMs Wall-clock budget for the whole random phase — running out fails with TimeBudgetExceededException
shrink ShrinkMode::Full (default), Off (report the input as generated) or Bounded with a budget
shrinkBudgetMs Wall-clock budget for the descent — the one knob that costs determinism, since how far it gets depends on how long the body takes
phases Stages to perform (Phase::Examples, Corpus, Random, Shrink); a subset trades coverage for time on purpose
derandomize Derives an unset seed from the property id instead of drawing one; an attribute seed still wins
path Replays a recorded shrink descent (CounterExample::$path) instead of searching for it; requires seed
edgeCases EdgeCases::None turns off the numeric boundary bias — for a property the edges only cost runs
auto Derives generators from the property's signature for every parameter the provider does not cover; the provider becomes partial overrides. Off by default, and stays off
throws The exception class every run must throw — a run that throws it passes, one that returns normally or throws another class fails and shrinks. The per-run replacement for #[ExpectException], which is refused on a property
exhaustive Walk the whole parameter domain instead of sampling it when every generator is Enumerable and the product fits exhaustiveBudget; otherwise the phase samples and a warning says why. runs is ignored when it walks — see the core README
exhaustiveBudget The largest domain exhaustive walks (default 10 000)
flakyReplays Re-executions of the minimised counterexample (default 2); one that passes marks the counterexample flaky, with a Flaky: line in the failure. 0 disables — see flaky detection
searchRuns Bodies the targeted search may execute after the random phase, for a body that calls Target::maximize()/minimize() (default 0 — no search) — see targeted search

Environment overrides

One rule decides who wins: the environment dials the suite, the attribute pins the property. PROPERTY_RUNS, PROPERTY_PHASES and PROPERTY_DERANDOMIZE are CI knobs and override the attribute; PROPERTY_SEED and PROPERTY_PATH replay one specific failure and yield to what the attribute wrote down.

Variable Effect
PROPERTY_RUNS Positive integer that overrides every property's run count (dial runs up in CI)
PROPERTY_SEED Integer seed for any property whose attribute omits seed (replay a whole suite). An explicit attribute seed still wins
PROPERTY_VERBOSE Logs every run's generated arguments and each accepted shrink step. Off for '', 0, false, off and no (case-insensitive, trimmed); anything else enables.
PROPERTY_DB Directory path enabling the regression corpus, or a redis://host[:port][/db][?prefix=key-prefix] DSN (rediss:// for TLS) for a corpus shared between CI and developers. Unset means off, nothing is written
PROPERTY_PHASES Comma-separated stage list (examples,corpus,random,shrink, case-insensitive) that overrides the attribute — an unknown name throws rather than skipping a stage. examples,corpus is the fast pull-request gate
PROPERTY_DERANDOMIZE Derives every unset seed from the property id, making a whole suite reproducible without editing it. '' leaves the attribute alone; 0, false, off and no (case-insensitive, trimmed) force it off, overriding derandomize: true; anything else forces it on.
PROPERTY_PATH A recorded shrink descent replayed instead of searched for. Requires a pinned seedPROPERTY_SEED or the attribute's — and is refused without one, because an unseeded property gets a random seed and the path would replay a run that never happened. An attribute path wins. It describes one failure, so run it with a filter on that one test — every other property would report the path as stale
PROPERTY_EDGE_CASES mixin or none (case-insensitive) — the numeric boundary bias for the whole suite, overriding the attribute. An unknown value throws
PROPERTY_EXHAUSTIVE Turns exhaustive mode on for every property whose domain fits its budget (a nightly that proves the small domains). The same words as PROPERTY_DERANDOMIZE switch it off
PROPERTY_SEARCH_RUNS Non-negative integer overriding every property's searchRuns — give the targeted search a bigger budget on a nightly, or 0 to switch it off. A malformed value throws

Regression corpus

PROPERTY_DB takes either a directory or a Redis DSN:

PROPERTY_DB=/tmp/corpus                           vendor/bin/testo   # one machine
PROPERTY_DB=redis://127.0.0.1:6379                vendor/bin/testo   # shared
PROPERTY_DB=redis://redis:6379/2?prefix=suite-a:  vendor/bin/testo   # shared server, database 2, own prefix
PROPERTY_DB=rediss://redis.example.com            vendor/bin/testo   # TLS

The DSN has the shape everything else gives it (the IANA registration, predis, Symfony): the path is the database index, the key prefix is the prefix query parameter, rediss:// is TLS. The pre-0.7 form with the prefix in the path (redis://host/suite-a:) is refused with the new spelling in the message. The value is parsed by the engine's CorpusFactory, shared with the PHPUnit adapter.

A directory remembers a counterexample for whoever owns it — in CI, a machine deleted when the job ends. The Redis form is the same corpus, in the same document, shared: a failure found on a laptop replays in CI and one found in CI replays on the next laptop. It needs ext-redis or predis/predis; neither installed is an error rather than a silent fall back to the filesystem, because a suite told to share its corpus and quietly writing where nobody reads is worse than one that stops. A PROPERTY_DB with any other scheme — a Rediss:// typo, another backend — is likewise an error, never a directory named after the scheme. Credentials in the DSN (redis://user:pass@host) are rejected rather than silently dropped; configure Redis AUTH out of band.

How entries are recorded

Set PROPERTY_DB to a directory and every falsified property records its failure there. On the next run the recorded failures are replayed first (unless the attribute pins its own seed): one that still fails is reported immediately — as a RegressionViolationException for a stored-values entry — and one that no longer fails is pruned. The storage format is exactly the one rasuvaeff/property-testing 2.8 wrote, so existing CI corpora keep working after the migration. Storage details live in the core documentation.

Coverage attributes

The adapter aggregates the per-run TestResult attributes of every executed body — Testo codecov's CoverageResult among them — onto the single TestResult a property test reports. Property tests therefore appear in per-test coverage, and Infection runs them against mutants like any other test.

Stateful / model-based testing

The engine's state machine works unchanged under #[Property]:

#[Property(runs: 200)]
public function stackBehavesLikeItsModel(CommandSequence $sequence): void
{
    StateMachine::check($sequence, static fn(): Stack => new Stack());
}

/** @return array<string, \Rasuvaeff\PropertyTesting\ArbitraryInterface> */
public static 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()),
    ])];
}

See examples/state_machine.php for the full runnable stack example.

The rule-based façade works the same way — #[Rule], #[Precondition] and #[Invariant] on one machine class, Gen::rules(QueueMachine::class) as the generator, and $sequence->run(static fn () => new QueueMachine(new Queue())) in the body; the engine's README has the full example.

The interceptor reports what the engine measured beside the distribution line: each Classify::tabulate() table with its tag shares and the pairs hit together, whether exhaustive mode walked the domain or why it sampled, and the search report (evaluations, per label the best score and the number of improvements). PROPERTY_VERBOSE also logs every TargetImproved event with the input that scored it.

Generators

The full generator catalog (Gen::int()Gen::subset(), Gen::regex(), Gen::commands(), Gen::draw(), writing your own ArbitraryInterface) is the engine's API and is documented in the core README. Everything there is usable from a #[Property] test as-is.

Public API of this package

Type Role
Rasuvaeff\PropertyTesting\Property The attribute — the same FQCN 2.x shipped
Rasuvaeff\PropertyTesting\Testo\PropertyInterceptor Testo interceptor: resolves reflection conventions and environment into a core PropertyDefinition, maps the structured result to one TestResult

TestoTrialExecutor and VerboseListener are @internal: the interceptor's implementation, not a contract. The environment variables and the PROPERTY_DB DSN are parsed by the engine (EnvironmentOverrides, CorpusFactory), so they mean the same thing under the PHPUnit adapter.

Listeners

PropertyInterceptor::__construct(Messenger $messenger, ?Clock $clock = null, iterable $listeners = []) takes PropertyListener observers of the engine's lifecycle events (PropertyStarted, RunFailed, ShrinkAccepted, …), the counterpart of the PHPUnit adapter's listeners(...). The attribute self-registers an interceptor built by Testo's container, which knows nothing about your listeners, so hand it one of your own from a plugin in testo.php; Testo prefers a configured interceptor over the one the attribute would create:

use Internal\Container\Container;
use Rasuvaeff\PropertyTesting\Testo\PropertyInterceptor;
use Testo\Application\Config\ApplicationConfig;
use Testo\Common\PluginConfigurator;
use Testo\Pipeline\InterceptorCollector;

return new ApplicationConfig(
    suites: [/* … */],
    plugins: [
        new class implements PluginConfigurator {
            public function configure(Container $container): void
            {
                $container->get(InterceptorCollector::class)->addInterceptor(
                    $container->make(PropertyInterceptor::class, ['listeners' => [new MyListener()]]),
                );
            }
        },
    ],
);

make() builds the interceptor with its other dependencies resolved by the container. The PROPERTY_VERBOSE trace listener is appended automatically. A listener that throws aborts the run — engine policy.

Security

Generated values are pseudo-random (seeded MT19937), not cryptographic. Seeds are not secrets — they are printed in failure output by design. Treat PROPERTY_DB corpus files as test artifacts: they contain generated inputs verbatim, so do not point the variable at a directory that gets published.

Examples

See examples/#[Property] test cases in a Testo suite of their own: vendor/bin/testo --suite=Examples.

Development

make install     # composer install (Docker)
make build       # validate + normalize + require-checker + cs + psalm + tests
make cs-fix      # apply code style
make mutation    # infection mutation testing

License

BSD-3-Clause