Testing
Unit and functional test patterns with fakes
Testing
What's Included
- Unit tests with in-memory fakes (no database, no framework)
- Functional tests with real HTTP requests and database rollback
- Complete set of test doubles for all external dependencies
- PHPStan static analysis and PHP CS Fixer
Test Structure
tests/
├── Doubles/ # Fakes, stubs, and in-memory implementations
├── Unit/ # Fast tests, no database, no framework
│ ├── UserManagement/
│ ├── Subscription/
│ └── Shared/
└── Functional/ # Full HTTP tests with real database
├── UserManagement/
└── Subscription/
Running Tests
make test # All tests
make test-unit # Unit tests only
make test-functional # Functional tests only
Unit Tests
Unit tests run without a database or framework. They test command handlers and domain logic using in-memory fakes.
Key principles:
- Use fakes (in-memory implementations), not mocks
- Test the handler's behavior, not its implementation
- Set up test data manually — no fixtures or factories
class SignUpHandlerTest extends TestCase
{
private InMemoryUserRepository $userRepository;
private FakeEventBus $eventBus;
private SignUpHandler $handler;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$this->eventBus = new FakeEventBus();
$this->handler = new SignUpHandler(
$this->userRepository,
// ... other fakes
);
}
public function testSignUpCreatesUser(): void
{
$command = new SignUpCommand(
name: 'John Doe',
email: 'john@example.com',
password: 'password123',
);
($this->handler)($command);
$this->assertNotNull(
$this->userRepository->findByEmail(new Email('john@example.com'))
);
}
}
Functional Tests
Functional tests make real HTTP requests against the application with a real database. Each test runs inside a database transaction that is rolled back automatically.
class RegisterUserTest extends ApiTestCase
{
public function testRegisterSuccess(): void
{
$client = static::createClient();
$client->request('POST', '/api/v1/auth/register', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'securePassword123',
]));
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('token', $data);
$this->assertArrayHasKey('refreshToken', $data);
}
}
Test Doubles
All test doubles live in tests/Doubles/. The project uses fakes (in-memory implementations) rather than mocks:
| Double | Replaces |
|---|---|
InMemoryUserRepository | DoctrineUserRepository |
InMemorySubscriptionRepository | DoctrineSubscriptionRepository |
InMemoryEmailSender | BrevoTransactionalEmailSender |
FakePaymentGateway | StripePaymentGateway |
FakeEventBus | Symfony Messenger event bus |
FakeSocialTokenVerifier | FirebaseTokenVerifier |
FakePasswordHasher | Symfony PasswordHasher |
FailingEmailSender | Always throws (error path testing) |
FailingPaymentGateway | Always throws (error path testing) |