Skip to content

Placeholder BDD ​

This tutorial shows how to use placeholders during BDD workflows. Placeholders let you describe behaviors before knowing the final implementation details.

Why Placeholders in BDD? ​

In BDD, you often start with behavior descriptions before knowing:

  • Class names
  • Method names
  • Implementation details

Placeholders let you maintain traceability while keeping options open.

BDD Placeholder Workflow ​

1. Write acceptance test with behavior placeholder (@feature-name)
2. Write unit tests with same placeholder
3. Implement code with matching placeholder
4. Resolve placeholders when design is stable

Tutorial: Shopping Cart Feature ​

Let's build a shopping cart using placeholder-based BDD.

Step 1: Define Behaviors with Placeholders ​

Start with acceptance tests using behavior-focused placeholders:

php
<?php
// tests/Feature/ShoppingCartTest.php

describe('Shopping Cart', function () {
    test('user can add items to cart', function () {
        // We don't know the exact class structure yet
        $cart = createCart();
        $item = createItem('Widget', 1500);

        addToCart($cart, $item);

        expect(cartItemCount($cart))->toBe(1);
    })->links('@cart-add');

    test('user can see cart total', function () {
        $cart = createCart();

        addToCart($cart, createItem('A', 1000));
        addToCart($cart, createItem('B', 2000));

        expect(cartTotal($cart))->toBe(3000);
    })->links('@cart-total');

    test('user can remove items from cart', function () {
        $cart = createCart();
        $item = createItem('Widget', 1500);

        addToCart($cart, $item);
        removeFromCart($cart, $item);

        expect(cartItemCount($cart))->toBe(0);
    })->links('@cart-remove');
});
php
<?php
// tests/Feature/ShoppingCartTest.php

namespace Tests\Feature;

use PHPUnit\Framework\TestCase;
use TestFlowLabs\TestingAttributes\Links;

class ShoppingCartTest extends TestCase
{
    #[Links('@cart-add')]
    public function test_user_can_add_items_to_cart(): void
    {
        $cart = $this->createCart();
        $item = $this->createItem('Widget', 1500);

        $this->addToCart($cart, $item);

        $this->assertSame(1, $this->cartItemCount($cart));
    }

    #[Links('@cart-total')]
    public function test_user_can_see_cart_total(): void
    {
        $cart = $this->createCart();

        $this->addToCart($cart, $this->createItem('A', 1000));
        $this->addToCart($cart, $this->createItem('B', 2000));

        $this->assertSame(3000, $this->cartTotal($cart));
    }

    #[Links('@cart-remove')]
    public function test_user_can_remove_items_from_cart(): void
    {
        $cart = $this->createCart();
        $item = $this->createItem('Widget', 1500);

        $this->addToCart($cart, $item);
        $this->removeFromCart($cart, $item);

        $this->assertSame(0, $this->cartItemCount($cart));
    }
}

Step 2: Add Unit Tests with Same Placeholders ​

php
<?php
// tests/Unit/CartTest.php

describe('Cart Implementation', function () {
    describe('add item', function () {
        test('stores item in collection', function () {
            // Implementation will be determined by inner loop
        })->linksAndCovers('@cart-add');

        test('increments quantity for duplicate items', function () {
            // ...
        })->linksAndCovers('@cart-add');
    });

    describe('calculate total', function () {
        test('sums all item prices', function () {
            // ...
        })->linksAndCovers('@cart-total');

        test('returns zero for empty cart', function () {
            // ...
        })->linksAndCovers('@cart-total');
    });

    describe('remove item', function () {
        test('removes item from collection', function () {
            // ...
        })->linksAndCovers('@cart-remove');

        test('throws if item not in cart', function () {
            // ...
        })->linksAndCovers('@cart-remove');
    });
});
php
<?php
// tests/Unit/CartTest.php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use TestFlowLabs\TestingAttributes\LinksAndCovers;

class CartTest extends TestCase
{
    #[LinksAndCovers('@cart-add')]
    public function test_stores_item_in_collection(): void
    {
        // Implementation will be determined by inner loop
    }

    #[LinksAndCovers('@cart-add')]
    public function test_increments_quantity_for_duplicate_items(): void
    {
        // ...
    }

    #[LinksAndCovers('@cart-total')]
    public function test_sums_all_item_prices(): void
    {
        // ...
    }

    #[LinksAndCovers('@cart-remove')]
    public function test_removes_item_from_collection(): void
    {
        // ...
    }
}

Step 3: Implement with Matching Placeholders ​

php
<?php
// src/Cart/ShoppingCart.php

namespace App\Cart;

use TestFlowLabs\TestingAttributes\TestedBy;

class ShoppingCart
{
    private array $items = [];

    #[TestedBy('@cart-add')]
    public function addItem(CartItem $item): void
    {
        $key = $item->productId;

        if (isset($this->items[$key])) {
            $this->items[$key]->incrementQuantity();
        } else {
            $this->items[$key] = $item;
        }
    }

    #[TestedBy('@cart-remove')]
    public function removeItem(CartItem $item): void
    {
        $key = $item->productId;

        if (!isset($this->items[$key])) {
            throw new \InvalidArgumentException('Item not in cart');
        }

        unset($this->items[$key]);
    }

    #[TestedBy('@cart-total')]
    public function getTotal(): int
    {
        return array_reduce(
            $this->items,
            fn ($sum, $item) => $sum + $item->getTotalPrice(),
            0
        );
    }

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

Step 4: Preview Placeholder Pairing ​

bash
./vendor/bin/testlink pair --dry-run
Pairing Placeholders
────────────────────
Running in dry-run mode.

Found Placeholders
  ✓ @cart-add     1 production × 3 tests = 3 links
  ✓ @cart-total   1 production × 3 tests = 3 links
  ✓ @cart-remove  1 production × 3 tests = 3 links

Production Files
  src/Cart/ShoppingCart.php
    @cart-add → Tests\Feature\ShoppingCartTest::test_user_can_add_items_to_cart
    @cart-add → Tests\Unit\CartTest::test_stores_item_in_collection
    @cart-add → Tests\Unit\CartTest::test_increments_quantity_for_duplicate_items
    ...

Test Files
  tests/Feature/ShoppingCartTest.php
    @cart-add → App\Cart\ShoppingCart::addItem
  tests/Unit/CartTest.php
    @cart-add → App\Cart\ShoppingCart::addItem
    ...

Would modify 3 file(s) with 9 change(s).

Step 5: Resolve When Ready ​

Once you're happy with the implementation:

bash
./vendor/bin/testlink pair

N:M Matching in BDD ​

Placeholders create N:M relationships naturally:

Placeholder @cart-add matches:

Production (N=1):
  └── ShoppingCart::addItem

Tests (M=3):
  ├── Feature: user can add items to cart
  ├── Unit: stores item in collection
  └── Unit: increments quantity for duplicate items

Result: 1 × 3 = 3 links created

Placeholder Naming Strategies ​

By Feature ​

php
#[TestedBy('@checkout')]
#[TestedBy('@inventory')]
#[TestedBy('@notification')]

By User Story ​

php
#[TestedBy('@user-can-add-to-cart')]
#[TestedBy('@user-can-checkout')]

By Acceptance Criteria ​

php
#[TestedBy('@AC1-cart-shows-items')]
#[TestedBy('@AC2-cart-calculates-total')]

Best Practices ​

1. Use Descriptive Placeholders ​

php
// Good - describes behavior
->links('@user-adds-item-to-cart')

// Less clear - describes implementation
->links('@cart-add-method')
php
// All checkout-related behaviors
#[TestedBy('@checkout-validate')]
#[TestedBy('@checkout-payment')]
#[TestedBy('@checkout-confirmation')]

3. Resolve Before Merging ​

Always resolve placeholders before merging to main:

bash
# In CI
./vendor/bin/testlink validate

# Error: Found unresolved placeholder @cart-add

What's Next? ​

Released under the MIT License.