One feature — a cart pricing / discount engine (app/Pricing/PricingEngine.php) —
hardened through six quality lenses, one git commit per step. It starts
deliberately naive (four seeded flaws) and ends all‑green: CRAP ≤ 5, 100 %
mutation score, zero duplication.
The story lives in git history. The
demo.shharness time‑travels through it and shows each finding (red) → fix (green) on demand.
composer install && composer install -d tools/behat # root + isolated Behat
composer test:all # confirm all six green, warm caches
which php83 && herd coverage -v >/dev/null && echo OK # the two Herd deps- Coverage runs through
herd coverage(Xdebug); there is no driver in the bare CLI. - phpcpd runs under
php83— the abandoned 6.0.3 mis‑tokenises on PHP 8.4. - Keep
demo.shand this file untracked so they survivegit checkoutof old commits.
| Command | Does |
|---|---|
./demo.sh map |
step → commit table, where HEAD is |
./demo.sh run N |
check out + run step N's lens (1–6, or green) |
./demo.sh red N |
show the finding for step 3/4/5/6 |
./demo.sh fix N |
show the green result |
./demo.sh diff N [stat|code|tests] |
what step N changed (no checkout) |
./demo.sh goto N / home |
jump to a step / back to main |
Suggested run: map → run 1 → run 2 → red 3/fix 3 → red 4/fix 4 →
red 5/fix 5 → red 6/fix 6 → run green.
./demo.sh goto naive then open app/Pricing/PricingEngine.php. Everything is in one
method, the percent maths is copy‑pasted, and the total is never clamped:
public function priceFor(Cart $cart, ?string $couponCode = null): PriceBreakdown
{
$subtotal = $cart->subtotalCents();
$discount = 0;
// Volume discount tiers — the same 4 lines, pasted per tier.
if ($subtotal >= 100000) {
$points = 15;
$amount = $subtotal * $points;
$amount = $amount / 100;
$cents = (int) round($amount);
$discount = $discount + $cents;
} elseif ($subtotal >= 50000) {
$points = 10;
// ...same four lines...
} elseif ($subtotal >= 10000) {
$points = 5;
// ...same four lines...
}
// VIP: the same four lines again. Coupon percent: again.
// ...
// NOTE: no clamp — a big flat coupon on a small cart goes negative.
$total = $subtotal - $discount + $shipping;
return new PriceBreakdown($subtotal, $discount, $shipping, $total);
}| Seeded flaw | Caught by |
|---|---|
| No discount clamp → negative totals | Property testing (step 4) |
Monolithic, complexity‑12 priceFor |
CRAP (step 3) |
| Unpinned tier / shipping boundaries | Mutation (step 5) |
| Percent block duplicated ×3 | DRY (step 6) |
Run: ./demo.sh run 1 → 11 scenarios / 54 steps pass.
The .feature file is the human‑readable, human‑approved spec.
Scenario Outline: Volume discounts apply in tiers
Given a cart with a subtotal of <subtotal>
When I price the cart
Then the discount should be <discount>
Examples:
| subtotal | discount |
| 80.00 | 0.00 |
| 250.00 | 12.50 |
| 600.00 | 60.00 |
| 1500.00 | 225.00 |Step definitions just drive the engine (features/bootstrap/FeatureContext.php):
/** @When I price the cart with coupon :code */
public function iPriceTheCartWithCoupon(string $code): void
{
$this->breakdown = (new PricingEngine())->priceFor(
new Cart($this->items, $this->customerType),
$code,
);
}Say: "This is executable English. The examples deliberately avoid one edge case — keep that in mind for step 4."
Run: ./demo.sh run 2 → 8 tests pass. Green… but intentionally thin
(no boundaries, no free‑shipping / expired / VIP‑stack branches).
it('applies a 5% discount in the first volume tier', function () {
$breakdown = priceCart([new LineItem('Desk', 25000, 1)]);
expect($breakdown->discountCents)->toBe(1250);
});Say: "Looks tested, right? Let's ask the tools."
Red: ./demo.sh red 3 → the gate fails:
| Method | Complexity | CRAP |
| Coupon.php::fromCode | 7 | 7.54 |
| Coupon.php::isExpired | 2 | 6.00 |
| PricingEngine.php::priceFor | 12 | 12.93 |
Fix: ./demo.sh fix 3 → every method ≤ 5 (priceFor → 3), 14 tests, 100 % covered.
Code change — decompose the monolith (./demo.sh diff 3 code):
public function priceFor(Cart $cart, ?string $couponCode = null): PriceBreakdown
{
$subtotal = $cart->subtotalCents();
- $discount = 0;
- if ($subtotal >= 100000) { /* 4 lines */ }
- elseif ($subtotal >= 50000) { /* 4 lines */ }
- elseif ($subtotal >= 10000) { /* 4 lines */ }
- if ($cart->customerType === CustomerType::Vip) { /* 4 lines */ }
- // ...inline coupon switch, inline expiry check...
+ $coupon = Coupon::fromCode($couponCode);
+ $coupon = ($coupon !== null && ! $coupon->isExpired()) ? $coupon : null;
+
+ $discount = $this->discountCents($cart, $subtotal, $coupon);
+ $shipping = $this->shippingCents($subtotal, $coupon);
$total = $subtotal - $discount + $shipping;
return new PriceBreakdown($subtotal, $discount, $shipping, $total);
}
+
+private function volumeRate(int $subtotal): int { /* tier table, complexity 4 */ }
+private function couponDiscountCents(?Coupon $coupon, int $subtotal): int { /* ... */ }
+private function shippingCents(int $subtotal, ?Coupon $coupon): int { /* ... */ }Data‑drive the coupon catalog to cut fromCode from complexity 7 → 2:
- return match (strtoupper($code)) {
- 'WELCOME10' => new self('WELCOME10', CouponKind::Percent, 10),
- 'SAVE25' => new self('SAVE25', CouponKind::Flat, 2500),
- 'FREESHIP' => new self('FREESHIP', CouponKind::FreeShipping, 0),
- 'OLD5' => new self('OLD5', CouponKind::Percent, 5, new DateTimeImmutable('2020-01-01')),
- default => null,
- };
+ return self::catalog()[strtoupper($code)] ?? null;Say: "CRAP = complexity² × (1 − coverage)³ + complexity. At 100 % coverage it collapses to complexity — so the gate is really forcing us to decompose."
Red: ./demo.sh red 4 → Eris falsifies an invariant and shrinks to a minimal cart:
✗ test_the_total_is_never_negative
Failed asserting that -1800 is ... greater than 0. (empty cart + SAVE25: 0 − 2500 + 700)
Reproduce with: ERIS_SEED=1780675078206802 ...
The properties hold for any generated cart, not just the hand‑picked examples:
public function test_the_total_is_never_negative(): void
{
$this->forAll($this->carts(), $this->customerTypes(), $this->couponCodes())
->then(function (array $items, string $type, ?string $coupon): void {
$breakdown = $this->price($items, $type, $coupon);
$this->assertGreaterThanOrEqual(0, $breakdown->totalCents);
});
}Fix: ./demo.sh fix 4 → clamp the discount, add a regression test, 3 properties green.
$discount = $this->discountCents($cart, $subtotal, $coupon);
+$discount = $this->clampDiscount($discount, $subtotal);
$shipping = $this->shippingCents($subtotal, $coupon);private function clampDiscount(int $discount, int $subtotal): int
{
$maximum = intdiv($subtotal, 2); // never discount more than 50%
return $discount > $maximum ? $maximum : $discount;
}Say: "The example tests passed. Property testing found the input nobody thought of — and shrank a random failure down to the smallest reproducing cart."
Red: ./demo.sh red 5 → mutants survive (MSI ~80 %); the meaningful ones are the
boundaries nothing pins:
UNTESTED volumeRate >= 100000 → > 100000 (survives)
UNTESTED volumeRate >= 10000 → >= 10001 (survives)
UNTESTED shippingCents >= 100000 → > 100000 (survives)
Fix: ./demo.sh fix 5 → pin the boundaries, enforce the int contract, MSI → 94 %.
it('applies the exact volume rate at each tier boundary', function (int $subtotalCents, int $expectedDiscount) {
expect(priceCart([new LineItem('X', $subtotalCents, 1)])->discountCents)->toBe($expectedDiscount);
})->with([
'just below the 5% tier' => [9999, 0],
'exactly at the 5% tier' => [10000, 500],
'just below the 10% tier' => [49999, 2500],
'exactly at the 10% tier' => [50000, 5000],
'just below the 15% tier' => [99999, 10000],
'exactly at the 15% tier' => [100000, 15000],
]);Two structural tweaks kill the "equivalent" mutants too:
-namespace App\Pricing;
+declare(strict_types=1); // the int return contract now kills RemoveIntegerCast
+
+namespace App\Pricing;-return $discount > $maximum ? $maximum : $discount; // > vs >= is an equivalent mutant
+return min($discount, $maximum);Scope mutation to the engine with one line in the test file:
mutates(PricingEngine::class);Say: "All tests were green, yet you could change
>=to>and nothing failed. The six survivors that remain are all in duplicated rounding code — hold that thought."
Red: ./demo.sh red 6 → the same four lines, copy‑pasted:
Found 1 clones with 4 duplicated lines in 1 files:
- app/Pricing/PricingEngine.php:41-45
app/Pricing/PricingEngine.php:48-52 (tier block ≡ VIP block)
Fix: ./demo.sh fix 6 → extract percentOff(); no clones, and mutation jumps to 100 %.
-$points = $this->volumeRate($subtotal);
-$amount = $subtotal * $points;
-$amount = $amount / 100;
-$cents = (int) round($amount);
-$discount = $discount + $cents;
-if ($cart->customerType === CustomerType::Vip) {
- $points = 5;
- $amount = $subtotal * $points;
- $amount = $amount / 100;
- $cents = (int) round($amount);
- $discount = $discount + $cents;
-}
+$discount = $this->percentOff($subtotal, $this->volumeRate($subtotal));
+
+if ($cart->customerType === CustomerType::Vip) {
+ $discount += $this->percentOff($subtotal, 5);
+}/** The single home for the percent maths the tier, VIP, and coupon discounts share. */
private function percentOff(int $subtotal, int $points): int
{
return (int) round($subtotal * $points / 100);
}Rewriting the coupon path as an exhaustive match removes the last equivalent mutant
(a deleted early return now hits UnhandledMatchError instead of silently returning 0):
-if ($coupon->kind === CouponKind::Percent) {
- $points = $coupon->value;
- $amount = $subtotal * $points;
- $amount = $amount / 100;
- return (int) round($amount);
-} elseif ($coupon->kind === CouponKind::Flat) { ... }
+return match ($coupon->kind) {
+ CouponKind::Flat => $coupon->value,
+ CouponKind::Percent => $this->percentOff($subtotal, $coupon->value),
+ CouponKind::FreeShipping => 0,
+};Now that rounding lives in one place, two tests finish the kill:
it('rounds a fractional discount to the nearest cent', function (int $subtotalCents, int $expectedDiscount) {
expect(priceCart([new LineItem('X', $subtotalCents, 1)])->discountCents)->toBe($expectedDiscount);
})->with([
'rounds down' => [10004, 500], // 5% of 100.04 = 5.002
'rounds up' => [10018, 501], // 5% of 100.18 = 5.009
]);Say: "The duplication wasn't just ugly — it made the same rounding bug hide in three places. Collapse it to one, and two tests push the mutation score to 100 %."
./demo.sh run green # composer test:all| Lens | Result |
|---|---|
| Gherkin (Behat) | 11 scenarios / 54 steps |
| Unit + Property (Pest) | 28 tests, 628 assertions; engine 100 % covered |
| CRAP | every method ≤ 5 (priceFor 12.93 → 3) |
Mutation (pest --mutate) |
100 % MSI, 0 survivors |
| DRY (phpcpd) | no clones |
git log --oneline reads as the whole story, one commit per lens.
diff never moves HEAD, so you can stay on main and pull any step on demand:
./demo.sh diff 3 stat # file summary (good for a slide)
./demo.sh diff 4 code # the one-line clamp
./demo.sh diff 4 tests # the property test that forced it
git diff step2 step3 -- app/Pricing # any two guardrails, scoped
git show step4 # a whole commitFor prettier rendering: pipe through delta,
or git difftool / code --diff.
- Behat lives in
tools/behat/with its own dependency tree — Laravel 13 ships Symfony 8, which Behat doesn't support yet. - phpcpd 6.0.3 is abandoned and breaks on PHP 8.4, so
test:dryruns it underphp83. - Eris property tests are a plain PHPUnit
TestCase(use TestTrait) that Pest runs in‑suite; failures print anERIS_SEED=…line to reproduce deterministically.