Skip to content

onesyntax/levelup-example

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Six Testing Lenses — Live Demo Guide

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.sh harness time‑travels through it and shows each finding (red) → fix (green) on demand.


0. Before you present

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.sh and this file untracked so they survive git checkout of old commits.

The harness

Command Does
./demo.sh map step → commit table, where HEAD is
./demo.sh run N check out + run step N's lens (16, 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: maprun 1run 2red 3/fix 3red 4/fix 4red 5/fix 5red 6/fix 6run green.


The starting point — a naive engine

./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)

Step 1 · Gherkin acceptance (Behat)

Run: ./demo.sh run 111 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."


Step 2 · Unit tests (Pest)

Run: ./demo.sh run 28 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."


Step 3 · CRAP analysis

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 3every 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."


Step 4 · Property testing (Eris)

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."


Step 5 · Mutation testing (pest --mutate)

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."


Step 6 · DRY analysis (phpcpd)

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 %."


The finale

./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.


Showing diffs live

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 commit

For prettier rendering: pipe through delta, or git difftool / code --diff.

Tooling notes (good asides)

  • 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:dry runs it under php83.
  • Eris property tests are a plain PHPUnit TestCase (use TestTrait) that Pest runs in‑suite; failures print an ERIS_SEED=… line to reproduce deterministically.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors