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!
    }
}

Run autoload:

bash
composer dump-autoload --optimize

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.