Complete BDD Example
This tutorial walks through building a complete shopping cart feature using BDD with TestLink. You'll see the full workflow from acceptance tests to implementation.
What We're Building
A shopping cart that:
- Allows adding items
- Calculates totals
- Applies discount codes
- Processes checkout
Project Structure
src/
Cart/
ShoppingCart.php
CartItem.php
DiscountCalculator.php
tests/
Feature/
ShoppingCartFeatureTest.php
Unit/
ShoppingCartTest.php
CartItemTest.php
DiscountCalculatorTest.phpPart 1: Define Acceptance Scenarios
Start with high-level user scenarios:
php
<?php
// tests/Feature/ShoppingCartFeatureTest.php
use App\Cart\ShoppingCart;
use App\Cart\CartItem;
use App\Cart\DiscountCalculator;
describe('Shopping Cart Feature', function () {
test('customer can add items and see total', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'Widget', 2500, 2));
$cart->addItem(new CartItem('SKU-002', 'Gadget', 5000, 1));
expect($cart->getItemCount())->toBe(2);
expect($cart->getSubtotal())->toBe(10000); // (2500*2) + (5000*1)
})
->links(ShoppingCart::class.'::addItem')
->links(ShoppingCart::class.'::getSubtotal');
test('customer can apply discount code', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'Widget', 10000, 1));
$cart->applyDiscountCode('SAVE20'); // 20% off
expect($cart->getTotal())->toBe(8000);
})
->links(ShoppingCart::class.'::applyDiscountCode')
->links(ShoppingCart::class.'::getTotal');
test('customer can remove items', function () {
$cart = new ShoppingCart();
$item = new CartItem('SKU-001', 'Widget', 2500, 1);
$cart->addItem($item);
expect($cart->getItemCount())->toBe(1);
$cart->removeItem('SKU-001');
expect($cart->getItemCount())->toBe(0);
})
->links(ShoppingCart::class.'::removeItem');
test('cart handles quantity updates', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'Widget', 1000, 1));
$cart->addItem(new CartItem('SKU-001', 'Widget', 1000, 2)); // Add more
expect($cart->getQuantity('SKU-001'))->toBe(3);
expect($cart->getSubtotal())->toBe(3000);
})
->links(ShoppingCart::class.'::addItem')
->links(ShoppingCart::class.'::getQuantity');
});Part 2: Write Unit Tests
Now break down into unit tests:
php
<?php
// tests/Unit/ShoppingCartTest.php
use App\Cart\ShoppingCart;
use App\Cart\CartItem;
describe('ShoppingCart', function () {
describe('addItem', function () {
test('adds new item to cart', function () {
$cart = new ShoppingCart();
$item = new CartItem('SKU-001', 'Widget', 1000, 1);
$cart->addItem($item);
expect($cart->hasItem('SKU-001'))->toBeTrue();
})->linksAndCovers(ShoppingCart::class.'::addItem');
test('increases quantity when adding existing SKU', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'Widget', 1000, 1));
$cart->addItem(new CartItem('SKU-001', 'Widget', 1000, 2));
expect($cart->getQuantity('SKU-001'))->toBe(3);
})->linksAndCovers(ShoppingCart::class.'::addItem');
});
describe('removeItem', function () {
test('removes item by SKU', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'Widget', 1000, 1));
$cart->removeItem('SKU-001');
expect($cart->hasItem('SKU-001'))->toBeFalse();
})->linksAndCovers(ShoppingCart::class.'::removeItem');
test('throws exception for non-existent SKU', function () {
$cart = new ShoppingCart();
expect(fn () => $cart->removeItem('INVALID'))
->toThrow(\InvalidArgumentException::class);
})->linksAndCovers(ShoppingCart::class.'::removeItem');
});
describe('getSubtotal', function () {
test('calculates sum of all item totals', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'A', 1000, 2)); // 2000
$cart->addItem(new CartItem('SKU-002', 'B', 500, 3)); // 1500
expect($cart->getSubtotal())->toBe(3500);
})->linksAndCovers(ShoppingCart::class.'::getSubtotal');
test('returns zero for empty cart', function () {
$cart = new ShoppingCart();
expect($cart->getSubtotal())->toBe(0);
})->linksAndCovers(ShoppingCart::class.'::getSubtotal');
});
describe('applyDiscountCode', function () {
test('stores valid discount code', function () {
$cart = new ShoppingCart();
$cart->applyDiscountCode('SAVE20');
expect($cart->getDiscountCode())->toBe('SAVE20');
})->linksAndCovers(ShoppingCart::class.'::applyDiscountCode');
test('throws for invalid discount code', function () {
$cart = new ShoppingCart();
expect(fn () => $cart->applyDiscountCode('INVALID'))
->toThrow(\InvalidArgumentException::class);
})->linksAndCovers(ShoppingCart::class.'::applyDiscountCode');
});
describe('getTotal', function () {
test('returns subtotal when no discount', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'Widget', 10000, 1));
expect($cart->getTotal())->toBe(10000);
})->linksAndCovers(ShoppingCart::class.'::getTotal');
test('applies percentage discount', function () {
$cart = new ShoppingCart();
$cart->addItem(new CartItem('SKU-001', 'Widget', 10000, 1));
$cart->applyDiscountCode('SAVE20'); // 20% off
expect($cart->getTotal())->toBe(8000);
})->linksAndCovers(ShoppingCart::class.'::getTotal');
});
});Part 3: Implement the Classes
CartItem
php
<?php
// src/Cart/CartItem.php
namespace App\Cart;
class CartItem
{
public function __construct(
public readonly string $sku,
public readonly string $name,
public readonly int $priceInCents,
public int $quantity = 1
) {}
public function getTotalPrice(): int
{
return $this->priceInCents * $this->quantity;
}
public function addQuantity(int $amount): void
{
$this->quantity += $amount;
}
}DiscountCalculator
php
<?php
// src/Cart/DiscountCalculator.php
namespace App\Cart;
use TestFlowLabs\TestingAttributes\TestedBy;
class DiscountCalculator
{
private const DISCOUNT_CODES = [
'SAVE10' => 0.10,
'SAVE20' => 0.20,
'HALF' => 0.50,
];
#[TestedBy('Tests\Unit\DiscountCalculatorTest', 'returns true for valid code')]
#[TestedBy('Tests\Unit\DiscountCalculatorTest', 'returns false for invalid code')]
public function isValidCode(string $code): bool
{
return isset(self::DISCOUNT_CODES[$code]);
}
#[TestedBy('Tests\Unit\DiscountCalculatorTest', 'calculates percentage discount')]
public function calculate(int $subtotal, string $code): int
{
if (!$this->isValidCode($code)) {
return $subtotal;
}
$discount = self::DISCOUNT_CODES[$code];
return (int) ($subtotal * (1 - $discount));
}
}ShoppingCart
php
<?php
// src/Cart/ShoppingCart.php
namespace App\Cart;
use TestFlowLabs\TestingAttributes\TestedBy;
class ShoppingCart
{
private array $items = [];
private ?string $discountCode = null;
private DiscountCalculator $discountCalculator;
public function __construct(?DiscountCalculator $discountCalculator = null)
{
$this->discountCalculator = $discountCalculator ?? new DiscountCalculator();
}
#[TestedBy('Tests\Feature\ShoppingCartFeatureTest', 'customer can add items and see total')]
#[TestedBy('Tests\Feature\ShoppingCartFeatureTest', 'cart handles quantity updates')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'adds new item to cart')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'increases quantity when adding existing SKU')]
public function addItem(CartItem $item): void
{
if (isset($this->items[$item->sku])) {
$this->items[$item->sku]->addQuantity($item->quantity);
} else {
$this->items[$item->sku] = $item;
}
}
#[TestedBy('Tests\Feature\ShoppingCartFeatureTest', 'customer can remove items')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'removes item by SKU')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'throws exception for non-existent SKU')]
public function removeItem(string $sku): void
{
if (!isset($this->items[$sku])) {
throw new \InvalidArgumentException("Item {$sku} not in cart");
}
unset($this->items[$sku]);
}
#[TestedBy('Tests\Feature\ShoppingCartFeatureTest', 'customer can add items and see total')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'calculates sum of all item totals')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'returns zero for empty cart')]
public function getSubtotal(): int
{
return array_reduce(
$this->items,
fn ($sum, $item) => $sum + $item->getTotalPrice(),
0
);
}
#[TestedBy('Tests\Feature\ShoppingCartFeatureTest', 'customer can apply discount code')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'stores valid discount code')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'throws for invalid discount code')]
public function applyDiscountCode(string $code): void
{
if (!$this->discountCalculator->isValidCode($code)) {
throw new \InvalidArgumentException("Invalid discount code: {$code}");
}
$this->discountCode = $code;
}
#[TestedBy('Tests\Feature\ShoppingCartFeatureTest', 'customer can apply discount code')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'returns subtotal when no discount')]
#[TestedBy('Tests\Unit\ShoppingCartTest', 'applies percentage discount')]
public function getTotal(): int
{
$subtotal = $this->getSubtotal();
if ($this->discountCode === null) {
return $subtotal;
}
return $this->discountCalculator->calculate($subtotal, $this->discountCode);
}
public function hasItem(string $sku): bool
{
return isset($this->items[$sku]);
}
public function getQuantity(string $sku): int
{
return $this->items[$sku]?->quantity ?? 0;
}
public function getItemCount(): int
{
return count($this->items);
}
public function getDiscountCode(): ?string
{
return $this->discountCode;
}
}Part 4: Validate and Report
Run All Tests
bash
./vendor/bin/pest
# Feature
# ✓ customer can add items and see total
# ✓ customer can apply discount code
# ✓ customer can remove items
# ✓ cart handles quantity updates
# Unit
# ✓ adds new item to cart
# ✓ increases quantity when adding existing SKU
# ✓ removes item by SKU
# ✓ throws exception for non-existent SKU
# ✓ calculates sum of all item totals
# ✓ returns zero for empty cart
# ✓ stores valid discount code
# ✓ throws for invalid discount code
# ✓ returns subtotal when no discount
# ✓ applies percentage discountValidate Links
bash
./vendor/bin/testlink validateValidation Report
─────────────────
Link Summary
Feature test links: 7
Unit test links: 10
Total links: 17
✓ All links are valid!View Complete Report
bash
./vendor/bin/testlink reportCoverage Links Report
─────────────────────
App\Cart\ShoppingCart
addItem()
→ Tests\Feature\ShoppingCartFeatureTest::customer can add items and see total
→ Tests\Feature\ShoppingCartFeatureTest::cart handles quantity updates
→ Tests\Unit\ShoppingCartTest::adds new item to cart
→ Tests\Unit\ShoppingCartTest::increases quantity when adding existing SKU
removeItem()
→ Tests\Feature\ShoppingCartFeatureTest::customer can remove items
→ Tests\Unit\ShoppingCartTest::removes item by SKU
→ Tests\Unit\ShoppingCartTest::throws exception for non-existent SKU
getSubtotal()
→ Tests\Feature\ShoppingCartFeatureTest::customer can add items and see total
→ Tests\Unit\ShoppingCartTest::calculates sum of all item totals
→ Tests\Unit\ShoppingCartTest::returns zero for empty cart
applyDiscountCode()
→ Tests\Feature\ShoppingCartFeatureTest::customer can apply discount code
→ Tests\Unit\ShoppingCartTest::stores valid discount code
→ Tests\Unit\ShoppingCartTest::throws for invalid discount code
getTotal()
→ Tests\Feature\ShoppingCartFeatureTest::customer can apply discount code
→ Tests\Unit\ShoppingCartTest::returns subtotal when no discount
→ Tests\Unit\ShoppingCartTest::applies percentage discount
App\Cart\DiscountCalculator
isValidCode()
→ Tests\Unit\DiscountCalculatorTest::returns true for valid code
→ Tests\Unit\DiscountCalculatorTest::returns false for invalid code
calculate()
→ Tests\Unit\DiscountCalculatorTest::calculates percentage discount
Summary
Methods with tests: 7
Total test links: 17What You Learned
- BDD structure - Acceptance tests drive unit tests
- links() vs linksAndCovers() - Use
links()/#[Links]for acceptance,linksAndCovers()/#[LinksAndCovers]for unit - Double-loop workflow - Outer acceptance loop, inner unit loop
- Complete traceability - From user stories to implementation
What's Next?
- Run Validation in CI - Automate validation
- TDD Workflow - Understand the theory
- BDD Workflow - Advanced BDD concepts