Your First Bidirectional Link
In this tutorial, you'll learn how to create bidirectional links between production code and tests. These links create explicit traceability in both directions.
What is Bidirectional Linking?
Bidirectional linking means:
- Production → Test: Your production code declares which tests verify it (
#[TestedBy]) - Test → Production: Your tests declare which production methods they cover (
linksAndCovers()or#[LinksAndCovers])
Both directions should match. TestLink validates this synchronization.
Prerequisites
Make sure you've completed the Getting Started tutorial first.
Step 1: Create a Production Class
Let's create a simple UserValidator class:
<?php
// src/UserValidator.php
namespace App;
class UserValidator
{
public function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
public function isValidAge(int $age): bool
{
return $age >= 18 && $age <= 120;
}
}Step 2: Write Tests First (Without Links)
Let's write tests for our validator:
<?php
// tests/UserValidatorTest.php
use App\UserValidator;
describe('UserValidator', function () {
describe('isValidEmail', function () {
test('returns true for valid email', function () {
$validator = new UserValidator();
expect($validator->isValidEmail('user@example.com'))->toBeTrue();
});
test('returns false for invalid email', function () {
$validator = new UserValidator();
expect($validator->isValidEmail('invalid'))->toBeFalse();
});
});
describe('isValidAge', function () {
test('returns true for valid age', function () {
$validator = new UserValidator();
expect($validator->isValidAge(25))->toBeTrue();
});
test('returns false for age under 18', function () {
$validator = new UserValidator();
expect($validator->isValidAge(17))->toBeFalse();
});
});
});Step 3: Add Production-Side Links
Now add links to your production code:
<?php
// src/UserValidator.php
namespace App;
use TestFlowLabs\TestingAttributes\TestedBy;
class UserValidator
{
#[TestedBy('Tests\UserValidatorTest', 'isValidEmail returns true for valid email')]
#[TestedBy('Tests\UserValidatorTest', 'isValidEmail returns false for invalid email')]
public function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
#[TestedBy('Tests\UserValidatorTest', 'isValidAge returns true for valid age')]
#[TestedBy('Tests\UserValidatorTest', 'isValidAge returns false for age under 18')]
public function isValidAge(int $age): bool
{
return $age >= 18 && $age <= 120;
}
}Test Names in Pest
For Pest tests with describe blocks, the test name is the combination of describe and test names. For describe('isValidEmail') containing test('returns true for valid email'), the test name is isValidEmail returns true for valid email.
== PHPUnit + Attributes
<?php
// src/UserValidator.php
namespace App;
use TestFlowLabs\TestingAttributes\TestedBy;
class UserValidator
{
#[TestedBy('Tests\UserValidatorTest', 'test_returns_true_for_valid_email')]
#[TestedBy('Tests\UserValidatorTest', 'test_returns_false_for_invalid_email')]
public function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
#[TestedBy('Tests\UserValidatorTest', 'test_returns_true_for_valid_age')]
#[TestedBy('Tests\UserValidatorTest', 'test_returns_false_for_age_under_18')]
public function isValidAge(int $age): bool
{
return $age >= 18 && $age <= 120;
}
}== PHPUnit + @see
<?php
// src/UserValidator.php
namespace App;
class UserValidator
{
/**
* @see \Tests\UserValidatorTest::test_returns_true_for_valid_email
* @see \Tests\UserValidatorTest::test_returns_false_for_invalid_email
*/
public function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* @see \Tests\UserValidatorTest::test_returns_true_for_valid_age
* @see \Tests\UserValidatorTest::test_returns_false_for_age_under_18
*/
public function isValidAge(int $age): bool
{
return $age >= 18 && $age <= 120;
}
}:::
Step 4: Add Test-Side Links
Now add the reverse links in your tests:
<?php
// tests/UserValidatorTest.php
use App\UserValidator;
describe('UserValidator', function () {
describe('isValidEmail', function () {
test('returns true for valid email', function () {
$validator = new UserValidator();
expect($validator->isValidEmail('user@example.com'))->toBeTrue();
})->linksAndCovers(UserValidator::class.'::isValidEmail');
test('returns false for invalid email', function () {
$validator = new UserValidator();
expect($validator->isValidEmail('invalid'))->toBeFalse();
})->linksAndCovers(UserValidator::class.'::isValidEmail');
});
describe('isValidAge', function () {
test('returns true for valid age', function () {
$validator = new UserValidator();
expect($validator->isValidAge(25))->toBeTrue();
})->linksAndCovers(UserValidator::class.'::isValidAge');
test('returns false for age under 18', function () {
$validator = new UserValidator();
expect($validator->isValidAge(17))->toBeFalse();
})->linksAndCovers(UserValidator::class.'::isValidAge');
});
});Step 5: Validate the Links
Run validation to ensure both directions match:
./vendor/bin/testlink validateYou should see:
Validation Report
─────────────────
Link Summary
PHPUnit attribute links: 4
Pest method chain links: 0
@see tags: 0
Total links: 4
All links are valid!Step 6: View the Report
See the complete picture with the report command:
./vendor/bin/testlink report Coverage Links Report
─────────────────────
App\UserValidator
isValidEmail()
→ Tests\UserValidatorTest::test_returns_true_for_valid_email
→ Tests\UserValidatorTest::test_returns_false_for_invalid_email
isValidAge()
→ Tests\UserValidatorTest::test_returns_true_for_valid_age
→ Tests\UserValidatorTest::test_returns_false_for_age_under_18
Summary
Methods with tests: 2
Total test links: 4What Happens When Links Don't Match?
If you add a #[TestedBy] without a corresponding test link (->linksAndCovers() in Pest, #[LinksAndCovers] in PHPUnit, or @see tag), validation will fail:
./vendor/bin/testlink validate Validation Report
─────────────────
Link Summary
PHPUnit attribute links: 5
Pest method chain links: 0
@see tags: 0
Total links: 4
✗ Found 1 orphan TestedBy link(s):
App\UserValidator::isValidEmail
→ Tests\UserValidatorTest::test_new_test (test not found)This ensures your documentation stays accurate.
What's Next?
Now that you understand bidirectional linking:
- Understanding Reports - Learn to interpret report output
- Sync Links Automatically - Let TestLink propagate links for you
- TDD Workflow - Learn to add links during TDD
- Placeholder Strategy - Temporary markers for rapid development
Automate This!
Instead of manually adding links to both production and test files, you can add a link to one side and run testlink sync to propagate it automatically. See How-to: Sync Links Automatically.