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:
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.00Step 1.2: Run BDD Tests (First RED)
./vendor/bin/pest --bddOutput:
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 failedWhat 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
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:
composer dump-autoload --optimizeStep 1.4: Run Again
./vendor/bin/pest --bddOutput:
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 failedProgress! The first step passes (it does nothing, but it exists). Now we need the When step.
Step 1.5: Add When Step
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
./vendor/bin/pest --bddOutput:
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 failedNow 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
test('can create empty cart', function () {
$cart = new App\Models\Cart();
expect($cart->items())->toBeEmpty();
expect($cart->count())->toBe(0);
});Run unit test:
./vendor/bin/pest tests/Unit/CartTest.phpOutput:
FAIL can create empty cart
Error: Class "App\Models\Cart" not foundStep 2.2: Create Minimal Cart (GREEN)
Create app/Models/Cart.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:
./vendor/bin/pest tests/Unit/CartTest.phpOutput:
PASS can create empty cart
Tests: 1 passedStep 2.3: Check Outer Loop
./vendor/bin/pest --bddOutput:
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 failedThe 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:
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);
});./vendor/bin/pest tests/Unit/CartTest.phpOutput:
PASS can create empty cart
FAIL product has name and price
Error: Class "App\Models\Product" not foundStep 3.2: Create Product (GREEN)
Create app/Models/Product.php:
<?php
namespace App\Models;
class Product
{
public function __construct(
public readonly string $name,
public readonly float $price,
) {}
}./vendor/bin/pest tests/Unit/CartTest.phpOutput:
PASS can create empty cart
PASS product has name and price
Tests: 2 passedStep 3.3: Check Outer Loop
./vendor/bin/pest --bddOutput:
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 failedProgress! Now we need Cart::add().
Part 4: Third Inner Loop - Cart::add()
Step 4.1: Write Unit Test (RED)
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);
});./vendor/bin/pest tests/Unit/CartTest.phpOutput:
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
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);
}
}./vendor/bin/pest tests/Unit/CartTest.phpOutput:
PASS can create empty cart
PASS product has name and price
PASS can add product to cart
Tests: 3 passedStep 4.3: Check Outer Loop
./vendor/bin/pest --bddOutput:
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 failedExcellent! Two steps pass. Now we need the Then step.
Step 4.4: Add Then Steps
Update tests/Steps/CartSteps.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
./vendor/bin/pest --bddOutput:
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 failedThree steps pass! We need Cart::total().
Part 5: Fourth Inner Loop - Cart::total()
Step 5.1: Write Unit Test (RED)
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);
});./vendor/bin/pest tests/Unit/CartTest.phpOutput:
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)
public function total(): float
{
return array_reduce(
$this->items,
fn (float $sum, Product $item) => $sum + $item->price,
0.0
);
}./vendor/bin/pest tests/Unit/CartTest.phpOutput:
PASS can create empty cart
PASS product has name and price
PASS can add product to cart
PASS calculates cart total
Tests: 4 passedStep 5.3: Check Outer Loop - GREEN!
./vendor/bin/pest --bddOutput:
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 passedFirst 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
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.00Step 6.2: Run BDD Tests
./vendor/bin/pest --bddOutput:
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 passedTwo scenarios pass! The third needs a new step.
Part 7: Discount Feature
Step 7.1: Add Step Definition
#[When('I apply discount code {code}')]
public function applyDiscount(string $code, Cart $cart): Cart
{
$cart->applyDiscount($code);
return $cart;
}Step 7.2: Run BDD
./vendor/bin/pest --bddOutput:
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)
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);
});./vendor/bin/pest tests/Unit/CartTest.phpOutput:
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
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);
}
}./vendor/bin/pest tests/Unit/CartTest.phpOutput:
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 passedStep 7.5: Check Outer Loop - ALL GREEN!
./vendor/bin/pest --bddOutput:
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.15sAll 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
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
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
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
./vendor/bin/pest tests/Unit && ./vendor/bin/pest --bddOutput:
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 passedAll tests pass after refactoring!
Summary
What We Built
| Component | Purpose |
|---|---|
Cart | Shopping cart with items and discount |
Product | Product value object |
DiscountService | Discount calculation logic |
CartSteps | BDD 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 scenarioKey Takeaways
- Let tests drive development - Never write code without a failing test
- Small steps - Each test should require minimal code
- Outer loop guides - BDD tells you WHAT to build
- Inner loop builds - TDD tells you HOW to build it
- 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.phpExercises
Try extending this example:
Add quantity support
gherkinWhen I add 3 "Laravel T-Shirt" at $25.00 each to the cart Then the cart total should be $75.00Add remove item feature
gherkinWhen I remove "Laravel T-Shirt" from the cart Then the cart should be emptyAdd minimum purchase discount
gherkinGiven 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"