symfony-testing
Symfony testing — PHPUnit test organization, unit tests, integration tests, functional tests, TDD workflow, PHPStan, mocking ports, test patterns. Triggers on: test, PHPUnit, TDD, unit test, integrati
Files3
View on GitHubSKILL.mdskills/symfony-testing/SKILL.md
GitHubSymfony Testing
You are an expert in testing within Symfony hexagonal architecture.
When to Activate
- User needs to write tests
- User asks about test organization or TDD
- User needs mocking strategies for ports
- User asks about PHPStan or code quality
- User mentions test-driven development
Version Notes
| Topic | Rule | Versions |
|---|---|---|
| Test methods | test_ prefix or #[Test] attribute; never @test / @dataProvider doc-comments |
annotations removed in PHPUnit 12 |
| Data providers | #[DataProvider('name')] + public static function name(): iterable |
PHPUnit 10+ |
| Stubs vs mocks | createStub() for return values, createMock() only with expects() |
PHPUnit 13 emits notices for mocks without expectations |
| Assertions | self::assert*() (static) |
all |
| PHPUnit per PHP | 11 → PHP 8.2, 12 → PHP 8.3, 13 → PHP 8.4 | pick from the detected PHP version |
| PHPStan | 2.x, level 8 minimum (9-10 recommended for new code) | checkGenericClassInNonGenericObjectType removed in 2.0 |
| Foundry | 2.x: PersistentObjectFactory / ObjectFactory, defaults(), class() |
ModelFactory was Foundry 1 |
Test Directory Structure (Mirrors src/)
tests/
├── Unit/
│ └── Domain/
│ └── {Module}/
│ ├── Entity/
│ │ └── {Entity}Test.php
│ └── ValueObject/
│ └── {ValueObject}Test.php
├── Integration/
│ └── Application/
│ └── {Module}/
│ ├── Command/
│ │ └── {Handler}Test.php
│ └── Query/
│ └── {Handler}Test.php
├── Functional/
│ └── Presentation/
│ └── {Module}/
│ └── API/
│ └── {Controller}Test.php
└── Support/
├── Adapter/
│ ├── InMemory{Entity}Repository.php
│ ├── SequentialIdGenerator.php
│ └── FakePasswordHasher.php
└── Factory/
└── {Entity}Factory.php
Test Types by Layer
Unit Tests (Domain): Pure PHP, no framework
namespace Tests\Unit\Domain\User\ValueObject;
use App\Domain\User\Exception\InvalidEmailException;
use App\Domain\User\ValueObject\Email;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class EmailTest extends TestCase
{
public function test_valid_email_is_created(): void
{
$email = new Email('user@example.com');
self::assertSame('user@example.com', $email->value);
}
#[DataProvider('invalidEmails')]
public function test_invalid_email_throws_exception(string $invalid): void
{
$this->expectException(InvalidEmailException::class);
new Email($invalid);
}
/** @return iterable<string, array{string}> */
public static function invalidEmails(): iterable
{
yield 'no at sign' => ['invalid'];
yield 'empty' => [''];
yield 'no domain' => ['user@'];
}
public function test_emails_are_equal(): void
{
self::assertTrue((new Email('user@example.com'))->equals(new Email('user@example.com')));
}
}
Integration Tests (Application): In-memory adapters for ports
namespace Tests\Integration\Application\User\Command;
use App\Application\User\Command\RegisterUser;
use App\Application\User\Command\RegisterUserHandler;
use App\Domain\User\ValueObject\UserId;
use PHPUnit\Framework\TestCase;
use Tests\Support\Adapter\FakePasswordHasher;
use Tests\Support\Adapter\InMemoryUserRepository;
use Tests\Support\Adapter\SequentialIdGenerator;
final class RegisterUserHandlerTest extends TestCase
{
public function test_it_registers_a_user(): void
{
$users = new InMemoryUserRepository();
$handler = new RegisterUserHandler($users, new FakePasswordHasher(), new SequentialIdGenerator());
$id = $handler(new RegisterUser('test@example.com', 'Test', 'password123'));
self::assertNotNull($users->findById(new UserId($id)));
}
}
Prefer in-memory fakes over mocks for ports: tests then assert on state, not on call sequences, and survive refactoring.
Functional Tests (Presentation): Full HTTP stack
namespace Tests\Functional\Presentation\User\API;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class UserControllerTest extends WebTestCase
{
public function test_create_user(): void
{
$client = static::createClient();
$client->loginUser(/* a SecurityUser with ROLE_USER_CREATE */);
$client->jsonRequest('POST', '/api/users', [
'email' => 'test@example.com',
'name' => 'Test User',
'password' => 'Password123',
]);
self::assertResponseStatusCodeSame(201);
$response = json_decode((string) $client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
self::assertNotNull($response['result']['id']);
self::assertNull($response['error']);
}
}
$client->jsonRequest() and $client->loginUser() work on every supported version. loginUser() authenticates through the test session, so it fits session-based firewalls; for a stateless token firewall, send a real token header (e.g. HTTP_AUTHORIZATION) instead.
TDD Workflow
- Write test first (RED)
- Write minimal code to pass (GREEN)
- Refactor (REFACTOR)
- Run PHPStan and deptrac
- Repeat
References
See references/ for detailed guides:
test-organization.md- phpunit.xml.dist (PHPUnit 10-13), suites, PHPStan, CI, Foundry 2test-patterns.md- In-memory adapters, builders, Messenger transport assertions, functional patterns