Unit Testing
Unit tests verify individual classes in isolation using Pest PHP, a testing framework with expressive functional syntax.
Basic Structure
test('Title can be created from valid string', function (): void {
$title = Title::fromString('Clean Code');
expect($title->toString())->toBe('Clean Code');
});
test('Title rejects empty string', function (): void {
expect(fn () => Title::fromString(''))
->toThrow(InvalidArgumentException::class);
});
Expectations
expect($value)->toBe($expected); // Strict ===
expect($value)->toBeTrue();
expect($value)->toBeInstanceOf(Book::class);
expect($array)->toHaveCount(3);
expect($string)->toContain('substring');
Chained Expectations
Exception Testing
expect(fn () => Title::fromString(''))
->toThrow(InvalidArgumentException::class, 'Expected message');
Data-Driven Tests
test('Title accepts valid strings', function (string $input): void {
$title = Title::fromString($input);
expect($title->toString())->toBe($input);
})->with(['Clean Code', 'Domain-Driven Design', 'A']);
What to Unit Test
- Value Objects: Validation, equality, behavior
- Entities: Domain logic, state transitions
- Handlers: Business rules with mocked dependencies
Running Tests
task tests:unit # All unit tests
task tests:unit -- --filter="Title" # Filter by name
task tests:unit -- tests/Phexium/Unit/Domain/ # Specific directory
Pest Configuration
The tests/Pest.php file configures the test environment with custom features.
Slow Test Detection
Tests exceeding a configurable threshold are collected and reported at the end of the test run:
// Thresholds per test suite
$slowTestThreshold = match ($suite ?? null) {
'Unit' => 20, // 20ms for unit tests
'Integration' => 200, // 200ms for integration tests
default => 500, // 500ms for other tests
};
Slow tests appear in yellow at the end of the test output:
⚠ Detected 3 slow tests (threshold: 20ms):
45ms It validates complex ISBN checksum
32ms It handles large collection operations
25ms It processes multiple events
SlowTestCollector
The collector uses Pest hooks to measure test duration:
final class SlowTestCollector
{
public static function add(string $name, float $durationMs): void;
public static function report(): void;
}
uses()
->beforeEach(function (): void {
$this->slowTestStartTime = hrtime(true);
})
->afterEach(function (): void {
$durationMs = (hrtime(true) - $this->slowTestStartTime) / 1_000_000;
if ($durationMs > SLOW_TEST_THRESHOLD_MS) {
SlowTestCollector::add($this->name(), $durationMs);
}
})
->in(__DIR__);
Environment Loading
The .env file is loaded automatically for tests:
Best Practices
- One behavior per test
- Descriptive test names
- Arrange-Act-Assert pattern
- Prefer InMemory implementations over mocks
- No logic in tests (no if/loops)
- Keep unit tests under 20ms threshold
See Also
- Value Objects - Primary unit test target
- Entities - Entity behavior testing
- Commands & Handlers - Handler testing with mocks