Test Linking
EventFlow provides a bidirectional test linking system that connects your PHP bindings to their unit tests. This creates traceability, ensures coverage, and enables the eventflow test:binding command to run tests by pattern name.
Why Test Linking?
In a TDD workflow, you have two types of tests:
| Loop | Tests | Tool |
|---|---|---|
| Outer (BDD) | Flow scenarios | eventflow test order.flow |
| Inner (TDD) | Binding unit tests | eventflow test:binding "pattern" |
Test linking connects these two worlds:
┌─────────────────────────────────────────────────────────────────┐
│ TEST LINKING ECOSYSTEM │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Flow Files │ │ PHP Bindings │ │
│ │ │ │ │ │
│ │ ? cart is not │ ──────► │ #[Guard(...)] │ │
│ │ empty │ │ #[TestedBy()] │ │
│ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Test Classes │ │
│ │ │ │
│ │ #[Tests(...)] │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘Bidirectional Linking
The linking is bidirectional - both the binding and the test declare their relationship:
1. Binding → Test (#[TestedBy])
Bindings declare which test classes verify them:
<?php
namespace App\Order\Guards;
use EventFlow\Attributes\Guard;
use EventFlow\Attributes\TestedBy;
use EventFlow\Behavior\GuardBehavior;
use Tests\Order\Guards\CartNotEmptyGuardTest;
#[Guard('cart is not empty')]
#[TestedBy(CartNotEmptyGuardTest::class)]
class CartNotEmptyGuard extends GuardBehavior
{
public function __invoke(array $context): bool
{
$cart = $context['cart'] ?? null;
return $cart !== null && count($cart->items) > 0;
}
}2. Test → Binding (#[Tests])
Test classes declare which binding pattern they test:
<?php
namespace Tests\Order\Guards;
use PHPUnit\Framework\TestCase;
use App\Order\Guards\CartNotEmptyGuard;
use EventFlow\Testing\Attributes\Tests;
#[Tests('cart is not empty')]
class CartNotEmptyGuardTest extends TestCase
{
public function test_returns_false_when_cart_is_null(): void
{
$guard = new CartNotEmptyGuard();
$this->assertFalse(($guard)(['cart' => null]));
}
public function test_returns_true_when_cart_has_items(): void
{
$guard = new CartNotEmptyGuard();
$cart = new \stdClass();
$cart->items = [['id' => 1]];
$this->assertTrue(($guard)(['cart' => $cart]));
}
}N:M Linking
Links can be many-to-many:
One Test, Multiple Bindings
A test class can verify multiple related bindings:
#[Tests('cart is not empty')]
#[Tests('cart is valid')]
class CartGuardsIntegrationTest extends TestCase
{
public function test_checkout_requires_valid_non_empty_cart(): void
{
// Tests both guards working together
}
}One Binding, Multiple Tests
A binding can be verified by multiple test classes:
#[Guard('cart is not empty')]
#[TestedBy(CartNotEmptyGuardTest::class)] // Unit tests
#[TestedBy(CheckoutIntegrationTest::class)] // Integration tests
class CartNotEmptyGuard extends GuardBehavior
{
// ...
}Running Linked Tests
Test a Single Binding
$ eventflow test:binding "cart is not empty"
Testing binding: 'cart is not empty'
Binding: App\Order\Guards\CartNotEmptyGuard
✓ returns false when cart is null (2ms)
✓ returns false when cart is empty (1ms)
✓ returns true when cart has items (1ms)
3 passing (4ms)Test All Bindings
$ eventflow test:bindings
Testing all bindings...
Guards:
CartNotEmptyGuard ('cart is not empty')
✓ returns false when cart is null (2ms)
✓ returns false when cart is empty (1ms)
✓ returns true when cart has items (1ms)
CustomerLoggedInGuard ('@customer is logged in')
✓ returns false when customer is null (2ms)
✓ returns true when authenticated (1ms)
Actions:
IncrementRetryCountAction ('$retry_count increases by 1')
✓ increments retry count (1ms)
✓ defaults to one (1ms)
7 passing (9ms)Test Bindings for a Flow File
$ eventflow test:bindings order.flow
Testing bindings used in order.flow...
✓ 'cart is not empty' - 3 tests
✓ '@customer is logged in' - 3 tests
✓ '$retry_count is less than 3' - 5 tests
✓ 'send confirmation email' - 2 tests
13 passingCombined Flow and Binding Tests
$ eventflow test order.flow --with-bindings
Flow Tests: 9 passing
Binding Tests: 18 passing
────────────────────
Total: 27 tests passingPlaceholder System
During rapid TDD development, you may not want to specify exact class names. Use placeholders:
Using Placeholders
// Binding - use placeholder
#[Guard('cart is not empty')]
#[TestedBy('@cart-guard')] // Placeholder starting with @
class CartNotEmptyGuard { }
// Test - same placeholder
#[Tests('@cart-guard')]
class CartNotEmptyGuardTest { }Resolving Placeholders
When you're ready to finalize:
$ eventflow links:pair
Resolving placeholders...
@cart-guard
Binding: CartNotEmptyGuard ('cart is not empty')
Test: CartNotEmptyGuardTest
→ Replacing placeholder with actual references
@login-guard
Binding: CustomerLoggedInGuard ('@customer is logged in')
Test: CustomerLoggedInGuardTest
→ Replacing placeholder with actual references
2 placeholders resolvedThis updates the files to use proper class references:
// After resolution
#[Guard('cart is not empty')]
#[TestedBy(CartNotEmptyGuardTest::class)]
class CartNotEmptyGuard { }Link Reports
View All Links
$ eventflow links:report
Binding-Test Links Report
─────────────────────────
Guards:
'cart is not empty'
Binding: App\Order\Guards\CartNotEmptyGuard
Tests:
→ CartNotEmptyGuardTest::test_returns_false_when_cart_is_null
→ CartNotEmptyGuardTest::test_returns_false_when_cart_is_empty
→ CartNotEmptyGuardTest::test_returns_true_when_cart_has_items
Status: ✓ Linked (3 tests)
'@customer is logged in'
Binding: App\Order\Guards\CustomerLoggedInGuard
Tests:
→ CustomerLoggedInGuardTest (3 tests)
Status: ✓ Linked
'$retry_count is less than 3'
Binding: App\Order\Guards\RetryLimitGuard
Tests: None
Status: ⚠ No test coverage
Summary:
Bindings with tests: 2/3 (67%)
Total test links: 6
Missing coverage: 1 bindingReport for Specific Flow
$ eventflow links:report order.flow
# Shows only bindings used in order.flowLink Validation
Basic Validation
$ eventflow links:validate
Validating binding-test links...
✓ 'cart is not empty' - properly linked
✓ '@customer is logged in' - properly linked
⚠ '$retry_count is less than 3' - no test coverage
Validation passed with 1 warningStrict Mode (CI/CD)
$ eventflow links:validate --strict
Validating binding-test links...
Issues Found:
✗ Missing Test Coverage
Guard '$retry_count is less than 3' has no linked tests
Binding: App\Order\Guards\RetryLimitGuard
✗ Orphaned Test
CartNotEmptyGuardTest::test_handles_edge_case
Links to 'cart is not empty' but method not in #[TestedBy]
⚠ Unresolved Placeholder
@payment-guard not resolved to actual binding
Validation FAILED (strict mode)
2 errors, 1 warning
Exit code: 1Use in CI/CD pipelines:
# .github/workflows/test.yml
- name: Validate test links
run: eventflow links:validate --strictAuto-Sync Links
Automatically add missing #[TestedBy] attributes:
$ eventflow links:sync
Syncing binding-test links...
CartNotEmptyGuard
Adding: #[TestedBy(CartNotEmptyGuardTest::class)]
CustomerLoggedInGuard
Already synced
RetryLimitGuard
No test found - skipping
1 binding updatedConfiguration
Configure test linking in eventflow.php:
return [
'bindings' => [
'paths' => [
'App\\Order\\Guards',
'App\\Order\\Actions',
'App\\Order\\Events',
],
],
'tests' => [
'paths' => [
'Tests\\Order\\Guards',
'Tests\\Order\\Actions',
],
'framework' => 'phpunit', // or 'pest'
],
'links' => [
// Fail CI if bindings lack tests
'strict' => env('EVENTFLOW_STRICT_LINKS', false),
// Allow @placeholder syntax
'allow_placeholders' => true,
// Auto-discover test files
'auto_discover' => true,
],
];Attribute Reference
#[TestedBy]
Used on binding classes to declare test coverage:
use EventFlow\Attributes\TestedBy;
#[TestedBy(MyTest::class)] // Link to test class
#[TestedBy(MyTest::class, 'test_method')] // Link to specific method
#[TestedBy('@placeholder')] // Use placeholder
class MyGuard { }Parameters:
$testClass(string|class-string): Test class name or placeholder$testMethod(string|null): Optional specific test method
#[Tests]
Used on test classes to declare what binding they test:
use EventFlow\Testing\Attributes\Tests;
#[Tests('cart is not empty')] // Guard pattern
#[Tests('@customer is logged in')] // Actor guard pattern
#[Tests('send confirmation email')] // Action pattern
#[Tests('@cart-placeholder')] // Placeholder
class MyTest { }Parameters:
$bindingPattern(string): The exact pattern from the flow file or a placeholder
Best Practices
1. Link During TDD
Add links as you write tests:
// 1. Write test with #[Tests]
#[Tests('cart is not empty')]
class CartNotEmptyGuardTest { }
// 2. Implement binding with #[TestedBy]
#[Guard('cart is not empty')]
#[TestedBy(CartNotEmptyGuardTest::class)]
class CartNotEmptyGuard { }2. Use Strict Mode in CI
# Ensure all bindings have tests before merge
- run: eventflow links:validate --strict3. Regular Link Reports
# Weekly check for coverage gaps
eventflow links:report --format=json > coverage.json4. Resolve Placeholders Before PR
# Before creating a pull request
eventflow links:pair
eventflow links:validate --strictIntegration with IDE
PHPStorm
Navigate from binding to test:
- Click on
#[TestedBy(MyTest::class)] - Ctrl+Click to jump to test class
Navigate from test to binding:
- Click on
#[Tests('pattern')] - Use "Find Usages" to find the binding
VS Code
Install EventFlow extension for:
- Syntax highlighting for
#[Tests]patterns - Quick navigation between binding and test
- Inline coverage indicators
Related:
- Session 4b: TDD Implementation - See test linking in action
- PHP Binding Reference - Full attribute documentation
- CLI Reference - All eventflow commands