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:

DoubleTypeReplaces
InMemoryUserRepositoryFakeDoctrineUserRepository
InMemorySubscriptionRepositoryFakeDoctrineSubscriptionRepository
InMemoryEmailValidationTokenRepositoryFakeDoctrine token repository
InMemoryPasswordResetTokenRepositoryFakeDoctrine token repository
InMemorySubscriptionStatusReadModelRepositoryFakeDBAL read model
InMemoryUserProfileReadModelRepositoryFakeDBAL read model
InMemoryEmailSenderFakeBrevoTransactionalEmailSender
FakePaymentGatewayFakeStripePaymentGateway
FakeEventBusFakeSymfony Messenger event bus
FakeSocialTokenVerifierFakeFirebaseTokenVerifier
FakePasswordHasherFakeSymfony PasswordHasher
StubUrlGeneratorStubSymfony UrlGenerator
PasswordHashableUserHelperTest user with hashable password
FailingEmailSenderFakeAlways throws (error path testing)
FailingPaymentGatewayFakeAlways throws (error path testing)

Writing New Tests

Adding a Unit Test

  1. Create a test class extending TestCase
  2. Set up in-memory fakes in setUp()
  3. Create the handler with all fakes injected
  4. Prepare test data (create users, tokens, etc.)
  5. Call the handler and assert results

Adding a Functional Test

  1. Create a test class extending ApiTestCase
  2. Make HTTP requests with static::createClient()
  3. Assert response status codes and JSON content
  4. The database is rolled back after each test automatically