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:

DoubleReplaces
InMemoryUserRepositoryDoctrineUserRepository
InMemorySubscriptionRepositoryDoctrineSubscriptionRepository
InMemoryEmailSenderBrevoTransactionalEmailSender
FakePaymentGatewayStripePaymentGateway
FakeEventBusSymfony Messenger event bus
FakeSocialTokenVerifierFirebaseTokenVerifier
FakePasswordHasherSymfony PasswordHasher
FailingEmailSenderAlways throws (error path testing)
FailingPaymentGatewayAlways throws (error path testing)