# Symfony Testing Complete Guide ## Test Environment Setup ### Configuration ```yaml # config/packages/test/framework.yaml framework: test: true session: storage_factory_id: session.storage.factory.mock_file # config/packages/test/doctrine.yaml doctrine: dbal: url: '%env(resolve:DATABASE_URL_TEST)%' ``` ### Test Database Setup ```bash # Create test database php bin/console doctrine:database:create --env=test # Run migrations php bin/console doctrine:migrations:migrate --env=test --no-interaction # Load fixtures php bin/console doctrine:fixtures:load --env=test --no-interaction ``` ## Unit Testing ### Testing Services ```php namespace App\Tests\Service; use App\Service\PriceCalculator; use PHPUnit\Framework\TestCase; class PriceCalculatorTest extends TestCase { private PriceCalculator $calculator; protected function setUp(): void { $this->calculator = new PriceCalculator(); } public function testCalculatePrice(): void { $result = $this->calculator->calculate(100, 0.2, 10); $this->assertEquals(110, $result); } /** * @dataProvider priceProvider */ public function testCalculatePriceWithDataProvider( float $basePrice, float $tax, float $discount, float $expected ): void { $result = $this->calculator->calculate($basePrice, $tax, $discount); $this->assertEquals($expected, $result); } public function priceProvider(): array { return [ 'with tax and discount' => [100, 0.2, 10, 110], 'no tax' => [100, 0, 10, 90], 'no discount' => [100, 0.2, 0, 120], 'zero price' => [0, 0.2, 10, 0], ]; } public function testCalculatePriceThrowsExceptionForNegativePrice(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Price cannot be negative'); $this->calculator->calculate(-10, 0.2, 0); } } ``` ### Testing Entities ```php namespace App\Tests\Entity; use App\Entity\Product; use App\Entity\Category; use PHPUnit\Framework\TestCase; class ProductTest extends TestCase { public function testGettersAndSetters(): void { $product = new Product(); $product->setName('Test Product'); $this->assertEquals('Test Product', $product->getName()); $product->setPrice(99.99); $this->assertEquals(99.99, $product->getPrice()); $category = new Category(); $category->setName('Electronics'); $product->setCategory($category); $this->assertSame($category, $product->getCategory()); } public function testProductValidation(): void { $product = new Product(); // Test required fields $this->assertNull($product->getName()); // Test default values $this->assertTrue($product->isActive()); $this->assertEquals(0, $product->getStock()); } public function testProductSlugGeneration(): void { $product = new Product(); $product->setName('Test Product Name'); $this->assertEquals('test-product-name', $product->getSlug()); } } ``` ## Functional Testing ### Testing Controllers ```php namespace App\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use App\Repository\UserRepository; class ProductControllerTest extends WebTestCase { public function testIndexPageIsSuccessful(): void { $client = static::createClient(); $client->request('GET', '/products'); $this->assertResponseIsSuccessful(); $this->assertSelectorTextContains('h1', 'Products'); } public function testShowProduct(): void { $client = static::createClient(); $client->request('GET', '/products/1'); $this->assertResponseIsSuccessful(); $this->assertSelectorExists('.product-details'); } public function testCreateProductRequiresAuthentication(): void { $client = static::createClient(); $client->request('GET', '/products/new'); $this->assertResponseRedirects('/login'); } public function testCreateProductAsAuthenticatedUser(): void { $client = static::createClient(); // Authenticate user $userRepository = static::getContainer()->get(UserRepository::class); $testUser = $userRepository->findOneByEmail('admin@example.com'); $client->loginUser($testUser); // Access protected page $crawler = $client->request('GET', '/products/new'); $this->assertResponseIsSuccessful(); // Fill and submit form $form = $crawler->selectButton('Save')->form([ 'product[name]' => 'Test Product', 'product[price]' => '99.99', 'product[description]' => 'Test description', ]); $client->submit($form); $this->assertResponseRedirects('/products'); // Follow redirect $client->followRedirect(); $this->assertSelectorTextContains('.alert-success', 'Product created successfully'); } } ``` ### Testing Forms ```php namespace App\Tests\Form; use App\Form\ProductType; use App\Entity\Product; use Symfony\Component\Form\Test\TypeTestCase; class ProductTypeTest extends TypeTestCase { public function testSubmitValidData(): void { $formData = [ 'name' => 'Test Product', 'price' => 99.99, 'description' => 'Test description', 'stock' => 10, ]; $model = new Product(); $form = $this->factory->create(ProductType::class, $model); $expected = new Product(); $expected->setName('Test Product'); $expected->setPrice(99.99); $expected->setDescription('Test description'); $expected->setStock(10); $form->submit($formData); $this->assertTrue($form->isSynchronized()); $this->assertEquals($expected->getName(), $model->getName()); $this->assertEquals($expected->getPrice(), $model->getPrice()); $view = $form->createView(); $children = $view->children; foreach (array_keys($formData) as $key) { $this->assertArrayHasKey($key, $children); } } } ``` ## Integration Testing ### Testing API Endpoints ```php namespace App\Tests\Api; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use App\Entity\Product; class ProductApiTest extends ApiTestCase { public function testGetCollection(): void { $response = static::createClient()->request('GET', '/api/products'); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); $this->assertJsonContains([ '@context' => '/api/contexts/Product', '@id' => '/api/products', '@type' => 'hydra:Collection', ]); $this->assertCount(30, $response->toArray()['hydra:member']); } public function testCreateProduct(): void { $client = static::createClient(); // Authenticate $token = $this->getToken($client); $response = $client->request('POST', '/api/products', [ 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'json' => [ 'name' => 'New Product', 'price' => 149.99, 'description' => 'A new product', ], ]); $this->assertResponseStatusCodeSame(201); $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); $this->assertJsonContains([ '@type' => 'Product', 'name' => 'New Product', 'price' => 149.99, ]); } public function testUpdateProduct(): void { $client = static::createClient(); $iri = $this->findIriBy(Product::class, ['id' => 1]); $client->request('PUT', $iri, [ 'json' => [ 'price' => 199.99, ], ]); $this->assertResponseIsSuccessful(); $this->assertJsonContains([ '@id' => $iri, 'price' => 199.99, ]); } public function testDeleteProduct(): void { $client = static::createClient(); $iri = $this->findIriBy(Product::class, ['id' => 1]); $client->request('DELETE', $iri); $this->assertResponseStatusCodeSame(204); // Verify deletion $this->assertNull( static::getContainer()->get('doctrine')->getRepository(Product::class)->find(1) ); } private function getToken($client): string { $response = $client->request('POST', '/api/login', [ 'json' => [ 'email' => 'admin@example.com', 'password' => 'password123', ], ]); return $response->toArray()['token']; } } ``` ### Testing Services with Database ```php namespace App\Tests\Service; use App\Service\OrderService; use App\Entity\Order; use App\Entity\Product; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; class OrderServiceTest extends KernelTestCase { private ?OrderService $orderService; private ?\Doctrine\ORM\EntityManager $entityManager; protected function setUp(): void { $kernel = self::bootKernel(); $this->entityManager = $kernel->getContainer() ->get('doctrine') ->getManager(); $this->orderService = $kernel->getContainer() ->get(OrderService::class); } public function testCreateOrder(): void { // Create test product $product = new Product(); $product->setName('Test Product'); $product->setPrice(99.99); $product->setStock(10); $this->entityManager->persist($product); $this->entityManager->flush(); // Create order $order = $this->orderService->createOrder([ 'product_id' => $product->getId(), 'quantity' => 2, ]); $this->assertInstanceOf(Order::class, $order); $this->assertEquals(199.98, $order->getTotal()); $this->assertEquals(8, $product->getStock()); } protected function tearDown(): void { parent::tearDown(); $this->entityManager->close(); $this->entityManager = null; } } ``` ## Test Doubles & Mocking ### Using Prophecy ```php namespace App\Tests\Service; use App\Service\EmailService; use App\Service\NotificationService; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; class NotificationServiceTest extends TestCase { use ProphecyTrait; public function testSendNotification(): void { $emailService = $this->prophesize(EmailService::class); $emailService->send( 'user@example.com', 'Notification', 'You have a new notification' )->shouldBeCalledOnce(); $notificationService = new NotificationService($emailService->reveal()); $notificationService->notify('user@example.com', 'You have a new notification'); } } ``` ### Using PHPUnit Mock ```php namespace App\Tests\Repository; use App\Repository\ProductRepository; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Query; use PHPUnit\Framework\TestCase; class ProductRepositoryTest extends TestCase { public function testFindActiveProducts(): void { $query = $this->createMock(Query::class); $query->expects($this->once()) ->method('getResult') ->willReturn(['product1', 'product2']); $qb = $this->getMockBuilder(\Doctrine\ORM\QueryBuilder::class) ->disableOriginalConstructor() ->getMock(); $qb->expects($this->once()) ->method('andWhere') ->with('p.active = :active') ->willReturnSelf(); $qb->expects($this->once()) ->method('setParameter') ->with('active', true) ->willReturnSelf(); $qb->expects($this->once()) ->method('getQuery') ->willReturn($query); $em = $this->createMock(EntityManager::class); $em->expects($this->once()) ->method('createQueryBuilder') ->willReturn($qb); $repository = new ProductRepository($em); $result = $repository->findActiveProducts(); $this->assertCount(2, $result); } } ``` ## Test Data Fixtures ### Creating Fixtures ```php namespace App\DataFixtures; use App\Entity\User; use App\Entity\Product; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class AppFixtures extends Fixture { public const ADMIN_USER_REFERENCE = 'admin-user'; public function __construct( private UserPasswordHasherInterface $passwordHasher ) {} public function load(ObjectManager $manager): void { // Create admin user $admin = new User(); $admin->setEmail('admin@example.com'); $admin->setRoles(['ROLE_ADMIN']); $admin->setPassword( $this->passwordHasher->hashPassword($admin, 'password123') ); $manager->persist($admin); $this->addReference(self::ADMIN_USER_REFERENCE, $admin); // Create products for ($i = 1; $i <= 20; $i++) { $product = new Product(); $product->setName('Product ' . $i); $product->setPrice(mt_rand(10, 200)); $product->setDescription('Description for product ' . $i); $product->setStock(mt_rand(0, 100)); $manager->persist($product); } $manager->flush(); } } ``` ### Fixture Dependencies ```php namespace App\DataFixtures; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; class OrderFixtures extends Fixture implements DependentFixtureInterface { public function load(ObjectManager $manager): void { $user = $this->getReference(AppFixtures::ADMIN_USER_REFERENCE); $order = new Order(); $order->setUser($user); // ... $manager->persist($order); $manager->flush(); } public function getDependencies(): array { return [ AppFixtures::class, ]; } } ``` ## Browser Testing with Panther ```php namespace App\Tests\E2E; use Symfony\Component\Panther\PantherTestCase; class E2ETest extends PantherTestCase { public function testHomePage(): void { $client = static::createPantherClient(); $crawler = $client->request('GET', '/'); $this->assertSelectorTextContains('h1', 'Welcome'); // Take screenshot $client->takeScreenshot('tests/screenshots/homepage.png'); } public function testJavaScriptInteraction(): void { $client = static::createPantherClient(); $crawler = $client->request('GET', '/interactive'); // Click button that triggers JavaScript $client->clickLink('Load More'); // Wait for AJAX content $client->waitFor('.loaded-content'); $this->assertSelectorExists('.loaded-content'); $this->assertSelectorTextContains('.loaded-content', 'Dynamic content'); } public function testFormSubmission(): void { $client = static::createPantherClient(); $crawler = $client->request('GET', '/contact'); // Fill form $crawler->filter('#contact_name')->sendKeys('John Doe'); $crawler->filter('#contact_email')->sendKeys('john@example.com'); $crawler->filter('#contact_message')->sendKeys('Test message'); // Submit $crawler->filter('#contact_submit')->click(); // Wait for response $client->waitFor('.alert-success'); $this->assertSelectorTextContains('.alert-success', 'Message sent successfully'); } } ``` ## Performance Testing ```php namespace App\Tests\Performance; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class PerformanceTest extends WebTestCase { public function testPageLoadTime(): void { $client = static::createClient(); $startTime = microtime(true); $client->request('GET', '/products'); $endTime = microtime(true); $loadTime = $endTime - $startTime; $this->assertLessThan(1, $loadTime, 'Page should load in less than 1 second'); } public function testDatabaseQueryCount(): void { $client = static::createClient(); $client->enableProfiler(); $client->request('GET', '/products'); if ($profile = $client->getProfile()) { $collector = $profile->getCollector('db'); $this->assertLessThan(10, $collector->getQueryCount(), 'Page should execute less than 10 queries'); } } public function testMemoryUsage(): void { $client = static::createClient(); $client->enableProfiler(); $client->request('GET', '/products'); if ($profile = $client->getProfile()) { $collector = $profile->getCollector('memory'); // Memory usage should be less than 50MB $this->assertLessThan(50 * 1024 * 1024, $collector->getMemory()); } } } ``` ## Testing Commands ```php namespace App\Tests\Command; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\CommandTester; class ImportCommandTest extends KernelTestCase { public function testExecute(): void { $kernel = static::createKernel(); $application = new Application($kernel); $command = $application->find('app:import-products'); $commandTester = new CommandTester($command); $commandTester->execute([ 'file' => 'tests/fixtures/products.csv', '--dry-run' => true, ]); $commandTester->assertCommandIsSuccessful(); $output = $commandTester->getDisplay(); $this->assertStringContainsString('Import completed', $output); $this->assertStringContainsString('10 products imported', $output); } public function testExecuteWithInvalidFile(): void { $kernel = static::createKernel(); $application = new Application($kernel); $command = $application->find('app:import-products'); $commandTester = new CommandTester($command); $commandTester->execute([ 'file' => 'nonexistent.csv', ]); $this->assertEquals(1, $commandTester->getStatusCode()); $this->assertStringContainsString('File not found', $commandTester->getDisplay()); } } ``` ## Code Coverage ### PHPUnit Configuration ```xml tests src src/Kernel.php src/DataFixtures ``` ### Running Tests with Coverage ```bash # Generate HTML coverage report php bin/phpunit --coverage-html coverage # Generate text coverage in console php bin/phpunit --coverage-text # Generate Clover XML for CI php bin/phpunit --coverage-clover coverage.xml ``` ## Test Best Practices 1. **Follow AAA pattern**: Arrange, Act, Assert 2. **One assertion per test method** when possible 3. **Use descriptive test names** that explain what is being tested 4. **Test edge cases** and error conditions 5. **Mock external dependencies** in unit tests 6. **Use fixtures** for consistent test data 7. **Keep tests independent** - each test should be able to run alone 8. **Test public API only** - don't test private methods directly 9. **Use data providers** for testing multiple scenarios 10. **Write tests first** (TDD) for critical business logic