Skip to content

Placeholder TDD ​

This tutorial teaches you how to use placeholder markers for rapid TDD iteration. Placeholders let you defer the naming decision until you're ready.

The Problem with Immediate Linking ​

During TDD, you often don't know the final class or method name until you're deep into implementation. Standard linking requires:

php
// You need to know the exact class name before it exists!
->linksAndCovers(PriceCalculator::class.'::calculateDiscount')

This creates friction during the exploratory phase of TDD.

Placeholders to the Rescue ​

Placeholders let you use temporary markers:

php
// Production code
#[TestedBy('@discount')]

// Test code
->linksAndCovers('@discount')

Later, run testlink pair to replace placeholders with real references.

Placeholder Syntax ​

Valid placeholder formats:

  • @A, @B, @C - Single letters for quick iteration
  • @feature-name - Descriptive names
  • @UserCreate - PascalCase
  • @user_update - snake_case

Must start with @ followed by a letter.

Tutorial: Building a Discount Calculator ​

Let's build a discount calculator using placeholder TDD.

Step 1: Start with a Placeholder Test ​

php
<?php
// tests/DiscountTest.php

test('premium users get 20% discount', function () {
    // We don't know the class name yet!
    $discount = calculateDiscount('premium', 100);

    expect($discount)->toBe(20);
})->linksAndCovers('@premium-discount');

:::

Step 2: Write Production Code with Matching Placeholder ​

As you implement, add the matching placeholder:

php
<?php
// src/Pricing/DiscountCalculator.php

namespace App\Pricing;

use TestFlowLabs\TestingAttributes\TestedBy;

class DiscountCalculator
{
    #[TestedBy('@premium-discount')]
    public function calculateForUserType(string $type, int $amount): int
    {
        return match ($type) {
            'premium' => (int) ($amount * 0.20),
            'gold' => (int) ($amount * 0.15),
            'silver' => (int) ($amount * 0.10),
            default => 0,
        };
    }
}

Step 3: Add More Tests with Same Placeholder ​

Multiple tests can use the same placeholder:

php
test('gold users get 15% discount', function () {
    $calculator = new DiscountCalculator();

    expect($calculator->calculateForUserType('gold', 100))->toBe(15);
})->linksAndCovers('@premium-discount');

test('regular users get no discount', function () {
    $calculator = new DiscountCalculator();

    expect($calculator->calculateForUserType('regular', 100))->toBe(0);
})->linksAndCovers('@premium-discount');

Step 4: Preview the Pairing ​

Before resolving, preview what will happen:

bash
./vendor/bin/testlink pair --dry-run
Pairing Placeholders
────────────────────
Running in dry-run mode. No files will be modified.

Found Placeholders
  ✓ @premium-discount  1 production × 3 tests = 3 links

Production Files
  src/Pricing/DiscountCalculator.php
    @premium-discount → Tests\DiscountTest::test_premium_users_get_20_percent_discount
    @premium-discount → Tests\DiscountTest::test_gold_users_get_15_percent_discount
    @premium-discount → Tests\DiscountTest::test_regular_users_get_no_discount

Test Files
  tests/DiscountTest.php
    @premium-discount → App\Pricing\DiscountCalculator::calculateForUserType

Dry run complete. Would modify 2 file(s) with 4 change(s).

Step 5: Resolve Placeholders ​

When you're happy with the names, resolve the placeholders:

bash
./vendor/bin/testlink pair
Pairing Placeholders
────────────────────

Resolving @premium-discount...
  ✓ Updated src/Pricing/DiscountCalculator.php
  ✓ Updated tests/DiscountTest.php

Summary
  Resolved: 1 placeholder(s)
  Modified: 2 file(s)
  Changes: 4

Step 6: Verify the Result ​

Your production code now has real links:

php
<?php
// src/Pricing/DiscountCalculator.php

namespace App\Pricing;

use TestFlowLabs\TestingAttributes\TestedBy;

class DiscountCalculator
{
    #[TestedBy('Tests\DiscountTest', 'test_premium_users_get_20_percent_discount')]
    #[TestedBy('Tests\DiscountTest', 'test_gold_users_get_15_percent_discount')]
    #[TestedBy('Tests\DiscountTest', 'test_regular_users_get_no_discount')]
    public function calculateForUserType(string $type, int $amount): int
    {
        return match ($type) {
            'premium' => (int) ($amount * 0.20),
            'gold' => (int) ($amount * 0.15),
            'silver' => (int) ($amount * 0.10),
            default => 0,
        };
    }
}

And your tests have real links:

php
test('premium users get 20% discount', function () {
    $calculator = new DiscountCalculator();

    expect($calculator->calculateForUserType('premium', 100))->toBe(20);
})->linksAndCovers(DiscountCalculator::class.'::calculateForUserType');

N:M Placeholder Matching ​

Placeholders support N:M relationships—multiple production methods linked to multiple tests.

Example: Multiple Methods ​

php
// Production code
class OrderService
{
    #[TestedBy('@order-flow')]
    public function create(array $data): Order { }

    #[TestedBy('@order-flow')]
    public function validate(Order $order): bool { }
}

// Test code
test('creates valid order', function () { })
    ->linksAndCovers('@order-flow');

test('validates order data', function () { })
    ->linksAndCovers('@order-flow');

Running pair creates:

  • 2 production methods × 2 tests = 4 links

Placeholder Workflow Tips ​

Use Short Placeholders for Exploration ​

php
// Quick iteration
#[TestedBy('@A')]
->linksAndCovers('@A')

// Later, rename to something meaningful
#[TestedBy('@user-registration')]
->linksAndCovers('@user-registration')

Use Descriptive Placeholders for Features ​

php
// Feature-based placeholders
#[TestedBy('@checkout-total')]
#[TestedBy('@checkout-tax')]
#[TestedBy('@checkout-shipping')]

Resolve Placeholders Before Committing ​

bash
# Check for unresolved placeholders
./vendor/bin/testlink validate

# Resolve all placeholders
./vendor/bin/testlink pair

What's Next? ​

Released under the MIT License.