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)
FailingGitHubOrganizationClientFakeAlways 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