Placeholder Pairing
Placeholder pairing allows you to use temporary markers during rapid TDD/BDD development, then resolve them to real class references later.
What are Placeholders?
During rapid development, writing full class references for every test link is tedious:
// Production - verbose
#[TestedBy('Tests\Unit\UserServiceTest', 'it creates a user')]
// Test - verbose
->linksAndCovers(UserService::class.'::create')Placeholders let you use short markers like @A or @user-create:
// Production - simple
#[TestedBy('@A')]
// Test - simple
->linksAndCovers('@A')When you're ready, run testlink pair to resolve all placeholders into real references.
Why Use Placeholders?
- Speed: Focus on writing code, not remembering class paths
- Flexibility: Rename classes without updating placeholders
- Iteration: Perfect for rapid TDD cycles
- Temporary Links: Establish connections before finalizing structure
Placeholder Syntax
Placeholders must:
- Start with
@followed by a letter - Contain only letters, numbers, underscores, and hyphens
Valid placeholders:
@A,@B,@C- Single letters for quick iteration@user-create- Descriptive names@UserCreate123- Mixed case with numbers@test_helper- Underscores allowed
Invalid placeholders:
@- Missing identifier@123- Cannot start with number@invalid!- Special characters not allowed
Using Placeholders
In Production Code
Use #[TestedBy] with a placeholder:
use TestFlowLabs\TestingAttributes\TestedBy;
class UserService
{
#[TestedBy('@user-create')]
public function create(array $data): User
{
// ...
}
#[TestedBy('@A')]
#[TestedBy('@B')]
public function update(User $user, array $data): User
{
// Multiple placeholders on same method
}
}In Test Code - Pest
Use linksAndCovers() or links() with a placeholder:
test('creates a user', function () {
// ...
})->linksAndCovers('@user-create');
test('validates user email', function () {
// ...
})->linksAndCovers('@user-create');
describe('UserService', function () {
test('updates user', function () {
// ...
})->linksAndCovers('@A');
});In Test Code - PHPUnit
Use #[LinksAndCovers] or #[Links] attributes with a placeholder:
use PHPUnit\Framework\TestCase;
use TestFlowLabs\TestingAttributes\LinksAndCovers;
class UserServiceTest extends TestCase
{
#[LinksAndCovers('@user-create')]
public function test_creates_user(): void
{
// ...
}
#[LinksAndCovers('@A')]
#[LinksAndCovers('@B')]
public function test_updates_user(): void
{
// Multiple placeholders
}
}N:M Matching
The same placeholder creates links between all matching production methods and all matching tests.
Example: If you have:
- 2 production methods with
#[TestedBy('@A')] - 3 tests with
->linksAndCovers('@A')
Result: 6 links (2 × 3 = 6)
// Production: 2 methods
class UserService
{
#[TestedBy('@A')]
public function create(): void { }
#[TestedBy('@A')]
public function update(): void { }
}
// Tests: 3 tests
test('creates user', fn() => ...)->linksAndCovers('@A');
test('validates user', fn() => ...)->linksAndCovers('@A');
test('stores user', fn() => ...)->linksAndCovers('@A');
// After pairing: each method links to all 3 tests
// create() → 3 tests
// update() → 3 tests
// Total: 6 linksRunning testlink pair
Preview Changes (Dry Run)
Always preview changes first:
testlink pair --dry-runOutput:
Pairing Placeholders
────────────────────
Running in dry-run mode. No files will be modified.
Scanning for placeholders...
Found Placeholders
──────────────────
✓ @user-create 1 production × 2 tests = 2 links
✓ @A 2 production × 3 tests = 6 links
✗ @orphan 1 production × 0 tests = 0 links
Production Files
────────────────
src/Services/UserService.php
@user-create → UserServiceTest::it creates a user
@user-create → UserServiceTest::it validates user
Test Files
──────────
tests/Unit/UserServiceTest.php
@user-create → UserService::create
Dry run complete. Would modify 2 file(s) with 8 change(s).
Run without --dry-run to apply changes:
testlink pairApply Changes
Once satisfied with the preview:
testlink pairOutput:
Pairing Placeholders
────────────────────
Scanning for placeholders...
Found Placeholders
──────────────────
✓ @user-create 1 production × 2 tests = 2 links
Production Files
────────────────
src/Services/UserService.php
@user-create → UserServiceTest::it creates a user
Test Files
──────────
tests/Unit/UserServiceTest.php
@user-create → UserService::create
✓ Pairing complete. Modified 2 file(s) with 2 change(s).Resolve Specific Placeholder
To resolve only one placeholder:
testlink pair --placeholder=@user-createThis is useful when you want to finalize one feature while keeping others as placeholders.
Error Handling
Orphan Production Placeholder
When a placeholder exists only in production code (no matching test):
Found Placeholders
──────────────────
✗ @orphan 1 production × 0 tests = 0 links
Errors
──────
✗ Placeholder @orphan has no matching test entriesSolution: Add a test with ->linksAndCovers('@orphan') or #[LinksAndCovers('@orphan')]
Orphan Test Placeholder
When a placeholder exists only in test code (no matching production):
Found Placeholders
──────────────────
✗ @missing 0 production × 2 tests = 0 links
Errors
──────
✗ Placeholder @missing has no matching production entriesSolution: Add #[TestedBy('@missing')] to the production method
Invalid Placeholder Format
testlink pair --placeholder=invalid ✗ Invalid placeholder format: invalidSolution: Ensure placeholder starts with @ followed by a letter
Detecting Unresolved Placeholders
The testlink validate command automatically detects unresolved placeholders in your codebase:
testlink validateOutput when placeholders are found:
Validation Report
─────────────────
Unresolved Placeholders
───────────────────────
⚠ @user-create (1 production, 2 tests)
⚠ @A (2 production, 0 tests)
⚠ Run "testlink pair" to resolve placeholders.
Link Summary
────────────
PHPUnit attribute links: 5
Pest method chain links: 10
Total links: 15
✓ All links are valid!Normal vs Strict Mode
| Mode | Placeholders Found | Exit Code |
|---|---|---|
| Normal | Warning only | 0 |
--strict | Fails validation | 1 |
Use --strict in CI/CD to ensure no placeholders are committed:
testlink validate --strictJSON Output
For CI/CD integration, the JSON output includes placeholder information:
testlink validate --json{
"valid": true,
"totalLinks": 15,
"unresolvedPlaceholders": [
{"id": "@user-create", "productionCount": 1, "testCount": 2},
{"id": "@A", "productionCount": 2, "testCount": 0}
]
}Complete Example
Step 1: Start with Placeholders
During TDD, quickly establish links:
// src/Services/OrderService.php
class OrderService
{
#[TestedBy('@order')]
public function create(array $items): Order
{
// TODO: implement
}
#[TestedBy('@order')]
public function calculate(Order $order): float
{
// TODO: implement
}
}// tests/Unit/OrderServiceTest.php
test('creates order from items', function () {
// ...
})->linksAndCovers('@order');
test('calculates order total', function () {
// ...
})->linksAndCovers('@order');
test('applies discount to order', function () {
// ...
})->linksAndCovers('@order');Step 2: Preview Resolution
testlink pair --dry-run Found Placeholders
──────────────────
✓ @order 2 production × 3 tests = 6 linksStep 3: Apply Changes
testlink pairStep 4: Verify Results
After pairing, your code becomes:
// src/Services/OrderService.php
class OrderService
{
#[TestedBy('Tests\Unit\OrderServiceTest', 'it creates order from items')]
#[TestedBy('Tests\Unit\OrderServiceTest', 'it calculates order total')]
#[TestedBy('Tests\Unit\OrderServiceTest', 'it applies discount to order')]
public function create(array $items): Order
{
// ...
}
#[TestedBy('Tests\Unit\OrderServiceTest', 'it creates order from items')]
#[TestedBy('Tests\Unit\OrderServiceTest', 'it calculates order total')]
#[TestedBy('Tests\Unit\OrderServiceTest', 'it applies discount to order')]
public function calculate(Order $order): float
{
// ...
}
}// tests/Unit/OrderServiceTest.php
test('creates order from items', function () {
// ...
})->linksAndCovers(OrderService::class.'::create')
->linksAndCovers(OrderService::class.'::calculate');
test('calculates order total', function () {
// ...
})->linksAndCovers(OrderService::class.'::create')
->linksAndCovers(OrderService::class.'::calculate');
test('applies discount to order', function () {
// ...
})->linksAndCovers(OrderService::class.'::create')
->linksAndCovers(OrderService::class.'::calculate');Best Practices
- Use descriptive placeholders for complex features:
@user-registrationinstead of@A - Use single letters for quick iteration:
@A,@Bduring initial TDD - Run dry-run first to verify expected changes
- Resolve incrementally with
--placeholder=@Xfor large codebases - Commit before pairing so you can easily revert if needed
Workflow Integration
Placeholder pairing fits naturally into TDD/BDD workflows:
- Write test with placeholder:
->linksAndCovers('@feature') - Write production with placeholder:
#[TestedBy('@feature')] - Iterate until feature is complete
- Run
testlink pairto finalize links - Commit the resolved code
See the TDD Workflow and BDD Workflow guides for detailed examples.
