Skip to content

Tutorial: Building a Shopping Cart ​

This tutorial walks you through building a shopping cart feature using the TDD Double Loop. We'll write only the code necessary to make tests pass, letting test feedback guide each step.

What We're Building ​

A shopping cart that can:

  • Add products with quantities
  • Calculate totals with discounts
  • Apply discount codes

Prerequisites ​

  • Laravel application with Pest BDD installed
  • Basic understanding of the Double Loop concept

Part 1: The Outer Loop (BDD) ​

Step 1.1: Write the Feature File ​

Create tests/Behaviors/shopping-cart.feature:

gherkin
Feature: Shopping Cart
  As a customer
  I want to manage items in my shopping cart
  So that I can purchase products

  Scenario: Add single item to cart
    Given I have an empty cart
    When I add product "Laravel T-Shirt" with price $25.00 to the cart
    Then the cart should contain 1 item
    And the cart total should be $25.00

Step 1.2: Run BDD Tests (First RED) ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart

  ✗ Scenario: Add single item to cart
    ✗ Given I have an empty cart
      Step not found: "I have an empty cart"

      Did you mean one of these?
        - No suggestions available

  Tests:    1 failed

What the Test Tells Us

The test tells us exactly what's missing: a step definition for "I have an empty cart". This is our first target.

Step 1.3: Create Step Definition Class ​

Create tests/Steps/CartSteps.php:

php
<?php

namespace Tests\Steps;

use TestFlowLabs\PestTestAttributes\Given;
use TestFlowLabs\PestTestAttributes\Then;
use TestFlowLabs\PestTestAttributes\When;

class CartSteps
{
    #[Given('I have an empty cart')]
    public function emptyCart(): void
    {
        // What should we return here?
        // We don't have a Cart class yet!
    }
}

Step 1.4: Run Again ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart

  ✗ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✗ When I add product "Laravel T-Shirt" with price $25.00 to the cart
      Step not found: "I add product \"Laravel T-Shirt\" with price $25.00 to the cart"

  Tests:    1 failed

Progress! The first step passes (it does nothing, but it exists). Now we need the When step.

Step 1.5: Add When Step ​

php
class CartSteps
{
    #[Given('I have an empty cart')]
    public function emptyCart(): Cart
    {
        return new Cart();  // Cart doesn't exist yet!
    }

    #[When('I add product {name} with price ${price} to the cart')]
    public function addProduct(string $name, float $price, Cart $cart): Cart
    {
        $cart->add(new Product($name, $price));
        return $cart;
    }
}

Step 1.6: Run Again ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart

  ✗ Scenario: Add single item to cart
    ✗ Given I have an empty cart
      Error: Class "App\Models\Cart" not found

  Tests:    1 failed

Now we have a concrete target: we need a Cart class. This is where we enter the Inner Loop (TDD).

Part 2: First Inner Loop - Cart Class ​

Step 2.1: Write Unit Test (RED) ​

Create tests/Unit/CartTest.php:

php
<?php

test('can create empty cart', function () {
    $cart = new App\Models\Cart();

    expect($cart->items())->toBeEmpty();
    expect($cart->count())->toBe(0);
});

Run unit test:

bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

FAIL  can create empty cart
Error: Class "App\Models\Cart" not found

Step 2.2: Create Minimal Cart (GREEN) ​

Create app/Models/Cart.php:

php
<?php

namespace App\Models;

class Cart
{
    private array $items = [];

    public function items(): array
    {
        return $this->items;
    }

    public function count(): int
    {
        return count($this->items);
    }
}

Run unit test:

bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart

Tests:    1 passed

Step 2.3: Check Outer Loop ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart

  ✗ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✗ When I add product "Laravel T-Shirt" with price $25.00 to the cart
      Error: Class "App\Models\Product" not found

  Tests:    1 failed

The Given step passes now! But we need a Product class.

Part 3: Second Inner Loop - Product Class ​

Step 3.1: Write Unit Test (RED) ​

Add to tests/Unit/CartTest.php:

php
test('product has name and price', function () {
    $product = new App\Models\Product('T-Shirt', 25.00);

    expect($product->name)->toBe('T-Shirt');
    expect($product->price)->toBe(25.00);
});
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
FAIL  product has name and price
Error: Class "App\Models\Product" not found

Step 3.2: Create Product (GREEN) ​

Create app/Models/Product.php:

php
<?php

namespace App\Models;

class Product
{
    public function __construct(
        public readonly string $name,
        public readonly float $price,
    ) {}
}
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
PASS  product has name and price

Tests:    2 passed

Step 3.3: Check Outer Loop ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart

  ✗ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✗ When I add product "Laravel T-Shirt" with price $25.00 to the cart
      Error: Call to undefined method App\Models\Cart::add()

  Tests:    1 failed

Progress! Now we need Cart::add().

Part 4: Third Inner Loop - Cart::add() ​

Step 4.1: Write Unit Test (RED) ​

php
test('can add product to cart', function () {
    $cart = new App\Models\Cart();
    $product = new App\Models\Product('T-Shirt', 25.00);

    $cart->add($product);

    expect($cart->count())->toBe(1);
    expect($cart->items())->toContain($product);
});
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
PASS  product has name and price
FAIL  can add product to cart
Error: Call to undefined method App\Models\Cart::add()

Step 4.2: Implement add() (GREEN) ​

Update app/Models/Cart.php:

php
<?php

namespace App\Models;

class Cart
{
    private array $items = [];

    public function add(Product $product): void
    {
        $this->items[] = $product;
    }

    public function items(): array
    {
        return $this->items;
    }

    public function count(): int
    {
        return count($this->items);
    }
}
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
PASS  product has name and price
PASS  can add product to cart

Tests:    3 passed

Step 4.3: Check Outer Loop ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart

  ✗ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $25.00 to the cart
    ✗ Then the cart should contain 1 item
      Step not found: "the cart should contain 1 item"

  Tests:    1 failed

Excellent! Two steps pass. Now we need the Then step.

Step 4.4: Add Then Steps ​

Update tests/Steps/CartSteps.php:

php
<?php

namespace Tests\Steps;

use App\Models\Cart;
use App\Models\Product;
use TestFlowLabs\PestTestAttributes\Given;
use TestFlowLabs\PestTestAttributes\Then;
use TestFlowLabs\PestTestAttributes\When;

class CartSteps
{
    #[Given('I have an empty cart')]
    public function emptyCart(): Cart
    {
        return new Cart();
    }

    #[When('I add product {name} with price ${price} to the cart')]
    public function addProduct(string $name, float $price, Cart $cart): Cart
    {
        $cart->add(new Product($name, $price));
        return $cart;
    }

    #[Then('the cart should contain {count} item')]
    #[Then('the cart should contain {count} items')]
    public function cartShouldContain(int $count, Cart $cart): void
    {
        expect($cart->count())->toBe($count);
    }

    #[Then('the cart total should be ${total}')]
    public function cartTotalShouldBe(float $total, Cart $cart): void
    {
        expect($cart->total())->toBe($total);
    }
}

Step 4.5: Run Again ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart

  ✗ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $25.00 to the cart
    ✓ Then the cart should contain 1 item
    ✗ And the cart total should be $25.00
      Error: Call to undefined method App\Models\Cart::total()

  Tests:    1 failed

Three steps pass! We need Cart::total().

Part 5: Fourth Inner Loop - Cart::total() ​

Step 5.1: Write Unit Test (RED) ​

php
test('calculates cart total', function () {
    $cart = new App\Models\Cart();
    $cart->add(new App\Models\Product('Shirt', 25.00));
    $cart->add(new App\Models\Product('Pants', 50.00));

    expect($cart->total())->toBe(75.00);
});
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
PASS  product has name and price
PASS  can add product to cart
FAIL  calculates cart total
Error: Call to undefined method App\Models\Cart::total()

Step 5.2: Implement total() (GREEN) ​

php
public function total(): float
{
    return array_reduce(
        $this->items,
        fn (float $sum, Product $item) => $sum + $item->price,
        0.0
    );
}
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
PASS  product has name and price
PASS  can add product to cart
PASS  calculates cart total

Tests:    4 passed

Step 5.3: Check Outer Loop - GREEN! ​

bash
./vendor/bin/pest --bdd

Output:

PASS  Feature: Shopping Cart

  ✓ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $25.00 to the cart
    ✓ Then the cart should contain 1 item
    ✓ And the cart total should be $25.00

Tests:    1 passed

First Scenario Complete!

The outer loop is GREEN. Our first scenario passes completely. Now we can either refactor or add more scenarios.

Part 6: Add More Scenarios ​

Step 6.1: Expand Feature File ​

gherkin
Feature: Shopping Cart
  As a customer
  I want to manage items in my shopping cart
  So that I can purchase products

  Scenario: Add single item to cart
    Given I have an empty cart
    When I add product "Laravel T-Shirt" with price $25.00 to the cart
    Then the cart should contain 1 item
    And the cart total should be $25.00

  Scenario: Add multiple items to cart
    Given I have an empty cart
    When I add product "Laravel T-Shirt" with price $25.00 to the cart
    And I add product "Pest Sticker" with price $5.00 to the cart
    Then the cart should contain 2 items
    And the cart total should be $30.00

  Scenario: Apply discount code
    Given I have an empty cart
    When I add product "Laravel T-Shirt" with price $100.00 to the cart
    And I apply discount code "SAVE20"
    Then the cart total should be $80.00

Step 6.2: Run BDD Tests ​

bash
./vendor/bin/pest --bdd

Output:

PASS  Feature: Shopping Cart

  ✓ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $25.00 to the cart
    ✓ Then the cart should contain 1 item
    ✓ And the cart total should be $25.00

  ✓ Scenario: Add multiple items to cart
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $25.00 to the cart
    ✓ And I add product "Pest Sticker" with price $5.00 to the cart
    ✓ Then the cart should contain 2 items
    ✓ And the cart total should be $30.00

  ✗ Scenario: Apply discount code
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $100.00 to the cart
    ✗ And I apply discount code "SAVE20"
      Step not found: "I apply discount code \"SAVE20\""

Tests:    1 failed, 2 passed

Two scenarios pass! The third needs a new step.

Part 7: Discount Feature ​

Step 7.1: Add Step Definition ​

php
#[When('I apply discount code {code}')]
public function applyDiscount(string $code, Cart $cart): Cart
{
    $cart->applyDiscount($code);
    return $cart;
}

Step 7.2: Run BDD ​

bash
./vendor/bin/pest --bdd

Output:

FAIL  Feature: Shopping Cart
  ✗ Scenario: Apply discount code
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $100.00 to the cart
    ✗ And I apply discount code "SAVE20"
      Error: Call to undefined method App\Models\Cart::applyDiscount()

We need applyDiscount() - back to the inner loop!

Step 7.3: Write Unit Test (RED) ​

php
test('applies percentage discount', function () {
    $cart = new App\Models\Cart();
    $cart->add(new App\Models\Product('Shirt', 100.00));

    $cart->applyDiscount('SAVE20');  // 20% off

    expect($cart->total())->toBe(80.00);
});
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
PASS  product has name and price
PASS  can add product to cart
PASS  calculates cart total
FAIL  applies percentage discount
Error: Call to undefined method App\Models\Cart::applyDiscount()

Step 7.4: Implement Discount (GREEN) ​

Update app/Models/Cart.php:

php
<?php

namespace App\Models;

class Cart
{
    private array $items = [];
    private float $discountPercent = 0;

    public function add(Product $product): void
    {
        $this->items[] = $product;
    }

    public function applyDiscount(string $code): void
    {
        // Simple implementation: extract number from code
        if (preg_match('/SAVE(\d+)/', $code, $matches)) {
            $this->discountPercent = (int) $matches[1];
        }
    }

    public function items(): array
    {
        return $this->items;
    }

    public function count(): int
    {
        return count($this->items);
    }

    public function total(): float
    {
        $subtotal = array_reduce(
            $this->items,
            fn (float $sum, Product $item) => $sum + $item->price,
            0.0
        );

        return $subtotal * (1 - $this->discountPercent / 100);
    }
}
bash
./vendor/bin/pest tests/Unit/CartTest.php

Output:

PASS  can create empty cart
PASS  product has name and price
PASS  can add product to cart
PASS  calculates cart total
PASS  applies percentage discount

Tests:    5 passed

Step 7.5: Check Outer Loop - ALL GREEN! ​

bash
./vendor/bin/pest --bdd

Output:

PASS  Feature: Shopping Cart

  ✓ Scenario: Add single item to cart
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $25.00 to the cart
    ✓ Then the cart should contain 1 item
    ✓ And the cart total should be $25.00

  ✓ Scenario: Add multiple items to cart
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $25.00 to the cart
    ✓ And I add product "Pest Sticker" with price $5.00 to the cart
    ✓ Then the cart should contain 2 items
    ✓ And the cart total should be $30.00

  ✓ Scenario: Apply discount code
    ✓ Given I have an empty cart
    ✓ When I add product "Laravel T-Shirt" with price $100.00 to the cart
    ✓ And I apply discount code "SAVE20"
    ✓ Then the cart total should be $80.00

Tests:    3 passed
Duration: 0.15s

All scenarios pass!

Part 8: Refactor ​

With all tests green, we can safely refactor.

Step 8.1: Extract Discount Service ​

Create tests/Unit/DiscountServiceTest.php:

php
<?php

use App\Services\DiscountService;

test('calculates percentage discount', function () {
    $service = new DiscountService();

    expect($service->calculate('SAVE20', 100.00))->toBe(80.00);
    expect($service->calculate('SAVE10', 100.00))->toBe(90.00);
    expect($service->calculate('INVALID', 100.00))->toBe(100.00);
});

Create app/Services/DiscountService.php:

php
<?php

namespace App\Services;

class DiscountService
{
    public function calculate(string $code, float $amount): float
    {
        if (preg_match('/SAVE(\d+)/', $code, $matches)) {
            $percent = (int) $matches[1];
            return $amount * (1 - $percent / 100);
        }

        return $amount;
    }
}

Step 8.2: Update Cart to Use Service ​

php
<?php

namespace App\Models;

use App\Services\DiscountService;

class Cart
{
    private array $items = [];
    private ?string $discountCode = null;

    public function __construct(
        private DiscountService $discounts = new DiscountService()
    ) {}

    public function add(Product $product): void
    {
        $this->items[] = $product;
    }

    public function applyDiscount(string $code): void
    {
        $this->discountCode = $code;
    }

    public function items(): array
    {
        return $this->items;
    }

    public function count(): int
    {
        return count($this->items);
    }

    public function total(): float
    {
        $subtotal = array_reduce(
            $this->items,
            fn (float $sum, Product $item) => $sum + $item->price,
            0.0
        );

        if ($this->discountCode) {
            return $this->discounts->calculate($this->discountCode, $subtotal);
        }

        return $subtotal;
    }
}

Step 8.3: Verify All Tests Still Pass ​

bash
./vendor/bin/pest tests/Unit && ./vendor/bin/pest --bdd

Output:

PASS  can create empty cart
PASS  product has name and price
PASS  can add product to cart
PASS  calculates cart total
PASS  applies percentage discount
PASS  calculates percentage discount

Tests:    6 passed

PASS  Feature: Shopping Cart
  ✓ Scenario: Add single item to cart
  ✓ Scenario: Add multiple items to cart
  ✓ Scenario: Apply discount code

Tests:    3 passed

All tests pass after refactoring!

Summary ​

What We Built ​

ComponentPurpose
CartShopping cart with items and discount
ProductProduct value object
DiscountServiceDiscount calculation logic
CartStepsBDD step definitions

The Rhythm We Followed ​

1. Write BDD scenario (Outer RED)
2. Run BDD → See failure
3. Add step definition
4. Run BDD → See next failure
5. Enter TDD loop:
   a. Write unit test (Inner RED)
   b. Write minimal code (Inner GREEN)
   c. Run unit test → Pass
6. Exit to outer loop
7. Run BDD → Check progress
8. Repeat until BDD passes (Outer GREEN)
9. Refactor with confidence
10. Add next scenario

Key Takeaways ​

  1. Let tests drive development - Never write code without a failing test
  2. Small steps - Each test should require minimal code
  3. Outer loop guides - BDD tells you WHAT to build
  4. Inner loop builds - TDD tells you HOW to build it
  5. Refactor safely - Green tests give confidence to improve code

Final Code Structure ​

app/
├── Models/
│   ├── Cart.php
│   └── Product.php
└── Services/
    └── DiscountService.php

tests/
├── Behaviors/
│   └── shopping-cart.feature
├── Steps/
│   └── CartSteps.php
└── Unit/
    ├── CartTest.php
    └── DiscountServiceTest.php

Exercises ​

Try extending this example:

  1. Add quantity support

    gherkin
    When I add 3 "Laravel T-Shirt" at $25.00 each to the cart
    Then the cart total should be $75.00
  2. Add remove item feature

    gherkin
    When I remove "Laravel T-Shirt" from the cart
    Then the cart should be empty
  3. Add minimum purchase discount

    gherkin
    Given discount "BIGSPEND" requires minimum $100 purchase
    When the cart total is $80.00
    And I apply discount code "BIGSPEND"
    Then I should see error "Minimum purchase not met"

Released under the MIT License.