Double-Loop TDD
This tutorial teaches you the double-loop TDD/BDD approach and how to use TestLink to maintain traceability at both levels.
What is Double-Loop TDD?
Double-loop TDD consists of two nested cycles:
- Outer loop (Acceptance) - High-level tests that describe behavior
- Inner loop (Unit) - Low-level tests that verify implementation
The outer loop drives the inner loop. You write a failing acceptance test, then use unit tests to build the implementation that makes it pass.
The Two Loops
OUTER LOOP (Acceptance)
├── Write failing acceptance test
├── INNER LOOP (Unit) - Repeat until acceptance test passes
│ ├── Write failing unit test
│ ├── Write minimal code to pass
│ └── Refactor
├── Acceptance test now passes
└── Refactor acceptance test if neededTutorial: User Registration Feature
Let's build a user registration feature using double-loop TDD.
Outer Loop: Write Failing Acceptance Test
Start with a high-level test that describes the desired behavior:
<?php
// tests/Feature/UserRegistrationTest.php
use App\Services\UserRegistration;
use App\Models\User;
test('user can register with valid details', function () {
// Given a registration service
$registration = new UserRegistration();
// When a user registers with valid details
$user = $registration->register([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'secret123',
]);
// Then a user is created
expect($user)->toBeInstanceOf(User::class);
expect($user->name)->toBe('John Doe');
expect($user->email)->toBe('john@example.com');
})->links(UserRegistration::class.'::register');Using links() / #[Links] for Acceptance Tests
We use links() (Pest) or #[Links] (PHPUnit) instead of linksAndCovers() / #[LinksAndCovers] for acceptance tests. This creates traceability without affecting code coverage metrics. Unit tests will provide the actual coverage.
Run the test—it fails because nothing exists yet:
./vendor/bin/pest tests/Feature
# Error: Class "App\Services\UserRegistration" not foundInner Loop 1: Validate Email
The acceptance test needs UserRegistration::register(). Let's build it piece by piece.
Step 1: Unit Test for Email Validation
<?php
// tests/Unit/UserRegistrationTest.php
use App\Services\UserRegistration;
describe('UserRegistration', function () {
describe('validateEmail', function () {
test('accepts valid email', function () {
$registration = new UserRegistration();
expect($registration->validateEmail('test@example.com'))->toBeTrue();
})->linksAndCovers(UserRegistration::class.'::validateEmail');
test('rejects invalid email', function () {
$registration = new UserRegistration();
expect($registration->validateEmail('invalid'))->toBeFalse();
})->linksAndCovers(UserRegistration::class.'::validateEmail');
});
});Step 2: Implement Email Validation
<?php
// src/Services/UserRegistration.php
namespace App\Services;
use TestFlowLabs\TestingAttributes\TestedBy;
class UserRegistration
{
#[TestedBy('Tests\Unit\UserRegistrationTest', 'accepts valid email')]
#[TestedBy('Tests\Unit\UserRegistrationTest', 'rejects invalid email')]
public function validateEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
}Step 3: Run Unit Tests
./vendor/bin/pest tests/Unit
# ✓ accepts valid email
# ✓ rejects invalid emailInner Loop 2: Hash Password
Step 1: Unit Test for Password Hashing
describe('hashPassword', function () {
test('returns hashed password', function () {
$registration = new UserRegistration();
$hashed = $registration->hashPassword('secret123');
expect($hashed)->not->toBe('secret123');
expect(password_verify('secret123', $hashed))->toBeTrue();
})->linksAndCovers(UserRegistration::class.'::hashPassword');
});Step 2: Implement Password Hashing
#[TestedBy('Tests\Unit\UserRegistrationTest', 'returns hashed password')]
public function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_DEFAULT);
}Inner Loop 3: Create User
Step 1: Unit Test for User Creation
describe('createUser', function () {
test('creates user with validated data', function () {
$registration = new UserRegistration();
$user = $registration->createUser(
name: 'John Doe',
email: 'john@example.com',
passwordHash: 'hashed_password'
);
expect($user)->toBeInstanceOf(User::class);
expect($user->name)->toBe('John Doe');
expect($user->email)->toBe('john@example.com');
})->linksAndCovers(UserRegistration::class.'::createUser');
});Step 2: Implement User Creation
#[TestedBy('Tests\Unit\UserRegistrationTest', 'creates user with validated data')]
public function createUser(string $name, string $email, string $passwordHash): User
{
return new User(
name: $name,
email: $email,
passwordHash: $passwordHash
);
}Inner Loop 4: Implement Register (Orchestration)
Step 1: Unit Test for Register
describe('register', function () {
test('validates email before registration', function () {
$registration = new UserRegistration();
expect(fn () => $registration->register([
'name' => 'John',
'email' => 'invalid',
'password' => 'secret',
]))->toThrow(InvalidArgumentException::class);
})->linksAndCovers(UserRegistration::class.'::register');
test('hashes password and creates user', function () {
$registration = new UserRegistration();
$user = $registration->register([
'name' => 'John',
'email' => 'john@example.com',
'password' => 'secret',
]);
expect($user->name)->toBe('John');
expect(password_verify('secret', $user->passwordHash))->toBeTrue();
})->linksAndCovers(UserRegistration::class.'::register');
});Step 2: Implement Register
#[TestedBy('Tests\Unit\UserRegistrationTest', 'validates email before registration')]
#[TestedBy('Tests\Unit\UserRegistrationTest', 'hashes password and creates user')]
#[TestedBy('Tests\Feature\UserRegistrationTest', 'user can register with valid details')]
public function register(array $data): User
{
if (!$this->validateEmail($data['email'])) {
throw new \InvalidArgumentException('Invalid email');
}
$passwordHash = $this->hashPassword($data['password']);
return $this->createUser(
name: $data['name'],
email: $data['email'],
passwordHash: $passwordHash
);
}Back to Outer Loop
Now run the acceptance test:
./vendor/bin/pest tests/Feature
# ✓ user can register with valid detailsThe acceptance test passes!
Final Validation
./vendor/bin/testlink validateValidation Report
─────────────────
Link Summary
PHPUnit attribute links: 6
Pest method chain links: 0
Total links: 6
✓ All links are valid!Key Takeaways
- Start with acceptance test - Describe the behavior you want
- Use
links()/#[Links]for acceptance tests - Traceability without coverage impact - Build with unit tests - Drive implementation piece by piece
- Use
linksAndCovers()/#[LinksAndCovers]for unit tests - Full traceability and coverage
What's Next?
- Acceptance to Unit - More patterns for driving unit tests
- Placeholder BDD - Using placeholders in BDD workflows