Testing
PHPUnit test suite — 72 tests across Unit, Integration and E2E layers.
Overview
The test suite covers three layers: Unit (pure logic), Integration (services + real database) and E2E (HTTP client + full stack). All tests run inside the Docker container against a dedicated test database.
| Suite | Tests | Description |
|---|---|---|
Unit |
21 | Pure PHP logic, no I/O (Services, Voters, Registries…) |
Integration |
31 | Services against a real PostgreSQL database via KernelTestCase |
Functional |
20 | HTTP client via WebTestCase — controllers, forms, redirects |
First-time setup
The integration and E2E suites require a dedicated test database. Create it once, then run tests at will:
make test-db
Running the tests
Use Make targets from your host machine:
| Command | Description |
|---|---|
make test |
Full test suite (Unit + Integration + E2E) |
make test-unit |
Unit tests only |
make test-integration |
Integration tests only (requires test-db) |
make test-functional |
E2E / functional tests only (requires test-db) |
Foundry factories
All entities have a Zenstruck Foundry factory under src/Factory/. Each factory ships default values and state methods for common scenarios:
| Factory | States |
|---|---|
src/Factory/UserFactory.php |
— |
src/Factory/OrganizationFactory.php |
— |
src/Factory/OrganizationMemberFactory.php |
owner() suspended() |
src/Factory/InvitationFactory.php |
expired() accepted() |
src/Factory/SubscriptionFactory.php |
trialing() active() pastDue() expired() |
src/Factory/PasswordResetTokenFactory.php |
— |
src/Factory/ScheduledTaskFactory.php |
lastRunSuccess() lastRunFailure() disabled() |
src/Factory/ScheduledTaskExecutionFactory.php |
— |
AppStory
AppStory populates the development database with realistic personas — two organisations, multiple members per role, subscription states and scheduled tasks. Run it with:
make seed
Test isolation
Stripe API calls are replaced by a no-op stub in the test environment. The stub is wired via config/services_test.yaml (loaded after config/services.yaml so it takes precedence):
| File | Role |
|---|---|
tests/Stub/StripeServiceStub.php |
No-op StripeService: returns fake IDs, never calls the Stripe API |
config/services_test.yaml |
Replaces StripeService with the stub in the test container |
config/packages/test/framework.yaml |
framework.test=true, disables form CSRF, sets rate limiters to no_limit |
Key patterns
A few conventions that apply across all test classes:
-
1
In E2E tests (WebTestCase), always call createClient() before any factory call — factories boot the kernel, and calling createClient() a second time throws a LogicException.
$client = static::createClient(); // FIRST $user = UserFactory::createOne(); // THEN factories -
2
Always use Factory::new()->state() — state() is an instance method in Foundry v2 and PHP 8.4 does not allow static calls to non-static methods.
// Correct (Foundry v2 + PHP 8.4) SubscriptionFactory::new()->trialing()->create(); // Wrong — PHP 8.4 fatal error SubscriptionFactory::trialing()->create(); -
3
All rate limiters use policy: no_limit in the test environment (config/packages/test/framework.yaml) so WebTestCase requests are never blocked.
config/packages/test/framework.yaml -
4
Form CSRF is disabled in tests (form.csrf_protection: false). Login and logout forms use the Twig csrf_token() function directly and are not affected.