Files
gh-atournayre-claude-market…/skills/symfony-skill/references/testing-complete.md
2025-11-29 17:59:04 +08:00

21 KiB

Symfony Testing Complete Guide

Test Environment Setup

Configuration

# 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

# 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

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

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

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

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

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

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

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

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

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

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

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

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

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

<!-- phpunit.xml.dist -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         backupGlobals="false"
         colors="true"
         bootstrap="tests/bootstrap.php">
    
    <php>
        <ini name="display_errors" value="1" />
        <ini name="error_reporting" value="-1" />
        <env name="APP_ENV" value="test" force="true" />
        <env name="SHELL_VERBOSITY" value="-1" />
        <env name="SYMFONY_PHPUNIT_REMOVE" value="" />
        <env name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
    </php>
    
    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
    
    <coverage processUncoveredFiles="true">
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory>src/Kernel.php</directory>
            <directory>src/DataFixtures</directory>
        </exclude>
    </coverage>
    
    <listeners>
        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
    </listeners>
</phpunit>

Running Tests with Coverage

# 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