for PHP Projects with PHPUnit and Eris
Example-based tests only check the cases a developer happened to think of. Property-based testing flips the approach around: instead of concrete inputs, you formulate invariants, properties that must hold for every conceivable input, and let the library search for thousands of random counterexamples.
Table of Contents
- 1. What sets property-based testing apart from example tests
- 2. Formulating invariants: the heart of the approach
- 3. Eris: property-based testing for PHP
- 4. Generators: producing structured random data
- 5. Shrinking: automatically finding minimal counterexamples
- 6. Practical examples: price calculation and validation
- 7. Property-based vs. example-based: a direct comparison
- 8. Summary
- 9. FAQ
1. What sets property-based testing apart from example tests
A classic PHPUnit test fixes concrete input values and checks concrete outputs: assertSame(119.00, $calculator->calculateGross(100.00)). That is an example test, it checks one specific case and makes no statement about any other input. Property-based testing instead formulates a property that must hold for all possible inputs: "The gross calculation must, for any non-negative amount, return a value greater than or equal to the net amount." That property is then checked not for one input, but for thousands of automatically generated ones.
The difference in discovery potential is substantial. Example tests find bugs for the inputs a developer happened to think of while writing the test. Property-based tests find bugs for inputs nobody would have expected: negative zero values, very large numbers, empty strings, unicode special characters, edge cases around integer overflow. This class of bugs, the edge cases that only surface in production through real users, is covered systematically by property-based testing before the software ever ships.
2. Formulating invariants: the heart of the approach
The hardest part of property-based testing is not the technical integration, it is formulating good invariants. An invariant is a property that must hold for all inputs drawn from a defined input space. Good invariants describe behavior at an abstract level without predicting concrete outputs. Bad invariants are either so general that they are always true ("the function returns something"), or so specific that they merely reproduce the concrete examples.
Proven patterns for invariants: symmetry (encode and decode cancel each other out), idempotence (applying an operation repeatedly gives the same result as applying it once), monotonicity (if the input grows, the output also grows), identity (a no-op operation leaves the value unchanged), equivalence (two different implementations produce the same result). These five patterns cover the majority of invariants that can be meaningfully formulated for business logic.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Eris\Generator;
use Eris\TestTrait;
use Mironsoft\Catalog\Model\PriceCalculator;
use PHPUnit\Framework\TestCase;
/**
* Property-based tests for PriceCalculator.
* Uses Eris library for generator-driven test input.
*
* Install: composer require --dev giorgiosironi/eris
*/
final class PriceCalculatorPropertyTest extends TestCase
{
use TestTrait;
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator(taxRate: 0.19);
}
/**
* Invariant: Gross price is always >= net price for non-negative inputs.
* Tested for 100 random float values in [0.0, 10000.0].
*/
public function testGrossIsAlwaysAtLeastNet(): void
{
$this->forAll(
Generator\float()->between(0.0, 10000.0)
)->then(function (float $net): void {
$gross = $this->calculator->calculateGross(net: $net);
self::assertGreaterThanOrEqual(
$net,
$gross,
"Gross {$gross} must be >= net {$net}"
);
});
}
/**
* Invariant: Applying tax and removing it returns the original value (within rounding).
* Symmetry property: decode(encode(x)) ≈ x
*/
public function testNetToGrossToNetIsApproximatelyIdentity(): void
{
$this->forAll(
Generator\float()->between(0.01, 9999.99)
)->then(function (float $net): void {
$gross = $this->calculator->calculateGross(net: $net);
$netAgain = $this->calculator->calculateNet(gross: $gross);
self::assertEqualsWithDelta(
$net,
$netAgain,
0.005,
"Round-trip failed for net={$net}"
);
});
}
}
3. Eris: property-based testing for PHP
Eris (by Giorgio Sironi) is the best known property-based testing library for PHP. It integrates directly into PHPUnit via a trait (TestTrait) and offers an extensive collection of generators for primitive types, arrays, strings, and custom objects. Installation is via Composer: composer require --dev giorgiosironi/eris. As an alternative, Eqentive offers a more modern option with native PHP 8 support and more strongly typed generators.
The core method of Eris is forAll(Generator)->then(callable). forAll accepts one or more generators, and then receives the generated values as arguments. By default Eris runs the property 100 times with different random values; this number can be adjusted via $this->limitTo(500). On failure, Eris automatically searches for the minimal input value that reproduces the failure (shrinking). The seed for the random generator is printed on failure so that failed tests can be reproduced exactly: $this->withSeed(12345)->forAll(...).
4. Generators: producing structured random data
Generators are the building blocks of property-based tests. They define the input space from which the library draws random values. Eris offers generators for all PHP primitive types plus composable building blocks for complex structures. The generator Generator\int() produces arbitrary integers; Generator\int()->between(1, 100) restricts the range. Generator\string() produces unicode strings, including whitespace, special characters, and control characters, exactly the inputs that manual tests typically forget.
For domain-specific data, generators can be composed: Generator\map(Generator\int()->between(1, 1000), fn($i) => "SKU-{$i}") produces valid SKU strings. Generator\tuple(Generator\int(), Generator\string()) produces pairs. Generator\vector(10, Generator\float()) produces arrays with exactly 10 float values. Generator\elements(['a', 'b', 'c']) picks randomly from a fixed set, useful for enum-like inputs or status values.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Eris\Generator;
use Eris\TestTrait;
use Mironsoft\Catalog\Model\SkuValidator;
use PHPUnit\Framework\TestCase;
/**
* Property-based tests using composed generators.
* Demonstrates: map, elements, tuple generators.
*/
final class SkuValidatorPropertyTest extends TestCase
{
use TestTrait;
private SkuValidator $validator;
protected function setUp(): void
{
$this->validator = new SkuValidator(maxLength: 64, allowedPattern: '/^[A-Z0-9\-]+$/');
}
/**
* Invariant: Any valid SKU must always pass validation.
* Generator produces structurally valid SKUs, tests that validator accepts them.
*/
public function testValidSkuAlwaysPassesValidation(): void
{
// Compose a generator for valid SKUs: uppercase letters, digits, hyphens
$skuGenerator = Generator\map(
Generator\tuple(
Generator\elements(['MRN', 'CAT', 'PROD', 'SKU']),
Generator\int()->between(100, 99999)
),
fn(array $parts): string => "{$parts[0]}-{$parts[1]}"
);
$this->forAll($skuGenerator)
->then(function (string $sku): void {
self::assertTrue(
$this->validator->isValid($sku),
"Expected valid SKU '{$sku}' to pass validation"
);
});
}
/**
* Invariant: SKUs exceeding max length must always fail validation.
* Generator: strings longer than maxLength.
*/
public function testTooLongSkuAlwaysFailsValidation(): void
{
$longSkuGenerator = Generator\map(
Generator\int()->between(65, 200),
fn(int $len): string => str_repeat('A', $len)
);
$this->forAll($longSkuGenerator)
->then(function (string $sku): void {
self::assertFalse(
$this->validator->isValid($sku),
"Expected too-long SKU to fail validation"
);
});
}
}
5. Shrinking: automatically finding minimal counterexamples
Shrinking is one of the most valuable features of property-based testing libraries. When a randomly generated value violates a property, the library automatically searches for a smaller, simpler value that triggers the same failure. From an initial counterexample such as the string "aXbYcZ123!@#", shrinking can extract the minimal counterexample "X", provided "X" triggers the same failure. This makes debugging considerably easier, because the developer immediately sees the smallest reproducible failure case, instead of having to analyze a complex generated example by hand.
Shrinking works automatically for all built-in generators in Eris. For custom generators, a shrinking strategy has to be implemented, or you use composed generators built from built-in blocks that already come with shrinking strategies. A common mistake is to write property tests whose input space is so unconstrained that meaningful shrinking never happens, because the generator has no structural relationship between the values it produces. Good generators model the domain of the code under test, not the complete set of all possible strings or integers.
6. Practical examples: price calculation and validation
Property-based testing delivers the greatest benefit for algorithms with clear mathematical properties: price calculations (monotonicity, symmetry between forward and reverse calculation), sorting functions (idempotence, length preservation, elements are retained), serialization (encode/decode are inverses), validation (structurally valid inputs pass, structurally invalid ones fail), and conversion logic (unit conversions, formatting). These categories cover a large share of the business logic found in typical PHP projects.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Test\Unit\Model;
use Eris\Generator;
use Eris\TestTrait;
use Mironsoft\Catalog\Model\CartCalculator;
use PHPUnit\Framework\TestCase;
/**
* Property-based tests for CartCalculator.
* Tests mathematical invariants of cart total calculation.
*/
final class CartCalculatorPropertyTest extends TestCase
{
use TestTrait;
private CartCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new CartCalculator();
}
/**
* Invariant: Total of multiple items >= total of any single item.
* Monotonicity: adding more items cannot decrease the total.
*/
public function testTotalIsMonotonicallyIncreasing(): void
{
$priceGenerator = Generator\float()->between(0.01, 999.99);
$itemCountGenerator = Generator\int()->between(1, 10);
$this->forAll($priceGenerator, $itemCountGenerator)
->then(function (float $unitPrice, int $quantity): void {
$singleTotal = $this->calculator->total([$unitPrice]);
$multipleTotal = $this->calculator->total(array_fill(0, $quantity, $unitPrice));
self::assertGreaterThanOrEqual(
$singleTotal,
$multipleTotal,
"Total for {$quantity} items must be >= total for 1 item"
);
});
}
/**
* Invariant: Sum is commutative, order of items does not change total.
* Symmetry property for unordered collections.
*/
public function testTotalIsIndependentOfItemOrder(): void
{
$priceListGenerator = Generator\vector(
5,
Generator\float()->between(0.01, 999.99)
);
$this->forAll($priceListGenerator)
->then(function (array $prices): void {
$original = $this->calculator->total($prices);
shuffle($prices);
$shuffled = $this->calculator->total($prices);
self::assertEqualsWithDelta(
$original,
$shuffled,
0.001,
'Cart total must be independent of item order'
);
});
}
}
7. Property-based vs. example-based: a direct comparison
Both testing styles complement each other. Property-based testing does not replace example tests, it adds a different dimension of insight. The table shows when each approach is the better fit.
| Criterion | Example-based Testing | Property-based Testing |
|---|---|---|
| Input coverage | Only explicitly chosen examples | Thousands of random inputs |
| Edge case discovery | Only known edge cases | Automatic, including unknown ones |
| Test readability | Very high, concrete values | Medium, abstract invariants |
| Learning effort | Low | Medium (formulating invariants) |
| Best suited for | Known business rules, regression | Algorithms, conversion, validation |
| Failure diagnosis | Directly traceable | With shrinking: minimal reproducible case |
The recommended strategy: start every new class with example tests for the most important known scenarios. If the class contains algorithms or conversion logic, add property-based tests for the identified invariants. Do not use property-based tests as a replacement for example tests, but as a complementary layer that systematically searches the input space. Teams that adopt property-based testing frequently report that the process of formulating invariants already delivers valuable insight into the intended behavior of the code, even before a single test has run.
Mironsoft
Property-based testing, test architecture, and quality assurance for PHP teams
Want to introduce property-based testing into your PHP project?
We identify suitable components in your production code, formulate invariants for your business logic, and integrate Eris into your PHPUnit suite, with a workshop for the entire development team.
Code analysis
Identify suitable algorithms and validation logic for property-based tests
Invariants workshop
Team workshop for formulating properties for existing business logic
Eris integration
Integrate Eris into your PHPUnit suite, implement custom generators
8. Summary
Property-based testing extends the PHPUnit toolkit by a fundamental dimension: instead of checking concrete examples, invariants are formulated for entire input ranges and automatically checked against thousands of random values. The Eris library makes this approach usable directly within PHPUnit, with shrinking support for minimal failure cases and reproducible seeds for failed tests. The most important invariant patterns, symmetry, idempotence, monotonicity, identity, and equivalence, cover the majority of testable properties in business logic.
Property-based testing does not replace example tests, it complements them as a second line of quality assurance. Teams that combine both approaches find classes of bugs that pure example tests cannot detect: boundary errors, integer overflows, floating-point precision problems, and unexpected side effects under extreme inputs. The barrier to entry is low: formulate one invariant for an existing class, install Eris, and write the first property test. The result is immediately visible.
Property-based Testing for PHP: the essentials at a glance
Invariants
Symmetry, idempotence, monotonicity, identity, equivalence, these five patterns cover the majority of business logic properties.
Eris integration
composer require --dev giorgiosironi/eris. Include TestTrait. forAll(Generator)->then(callable) as the basic structure for every property test.
Shrinking
Automatic for all built-in generators. On failure: the minimal input value is printed. The seed is noted for exact reproducibility.
Combination
Example tests for known business rules, property-based tests for algorithms and conversion logic. Both approaches complement each other.