Symfony Testing in 2026: PHPUnit, KernelTestCase and Functional Tests

Master Symfony testing with PHPUnit 12, KernelTestCase for integration tests, and WebTestCase for functional testing. Learn database isolation, authentication testing, and code coverage configuration.

Symfony PHP testing with PHPUnit dashboard showing test results and code coverage metrics

Symfony testing with PHPUnit 12 provides a structured approach to validating application behavior from unit tests through full HTTP request simulations. The framework's testing infrastructure integrates tightly with the service container, making it straightforward to test services, controllers, and database operations with minimal boilerplate.

Testing Stack in Symfony 7.3

Symfony 7.3 ships with PHPUnit 12 support, improved test isolation via ResetInterface, and the CrawlerSelector component for more readable functional test assertions. The WebTestCase now supports HTTP/2 push assertions out of the box.

Unit Testing Services with PHPUnit 12

Unit tests verify individual classes in isolation. For Symfony services that have no dependencies on the container, standard PHPUnit tests work without any framework involvement.

tests/Unit/Service/PriceCalculatorTest.phpphp
namespace App\Tests\Unit\Service;

use App\Service\PriceCalculator;
use PHPUnit\Framework\TestCase;

final class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        // Create instance directly - no container needed
        $this->calculator = new PriceCalculator(vatRate: 0.20);
    }

    public function testCalculateTotalWithVat(): void
    {
        $result = $this->calculator->calculateTotal(100.00);
        
        // Assert exact decimal value with delta for float comparison
        $this->assertEqualsWithDelta(120.00, $result, 0.001);
    }

    public function testCalculateTotalWithZeroAmount(): void
    {
        $result = $this->calculator->calculateTotal(0.00);
        
        $this->assertEqualsWithDelta(0.00, $result, 0.001);
    }
}

Pure unit tests run faster than integration tests and pinpoint failures precisely. Reserve them for business logic classes that transform data or perform calculations.

Integration Testing with KernelTestCase

When services depend on the Symfony container—database connections, cache adapters, or other services—KernelTestCase boots a minimal kernel and provides access to the service container.

tests/Integration/Repository/UserRepositoryTest.phpphp
namespace App\Tests\Integration\Repository;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class UserRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private UserRepository $repository;

    protected function setUp(): void
    {
        // Boot kernel and fetch services from container
        self::bootKernel();
        $container = static::getContainer();
        
        $this->em = $container->get(EntityManagerInterface::class);
        $this->repository = $container->get(UserRepository::class);
    }

    public function testFindByEmailReturnsUserWhenExists(): void
    {
        // Arrange: create and persist test user
        $user = new User();
        $user->setEmail('test@example.com');
        $user->setPassword('hashed_password');
        
        $this->em->persist($user);
        $this->em->flush();

        // Act: query through repository
        $found = $this->repository->findByEmail('test@example.com');

        // Assert: verify retrieval
        $this->assertNotNull($found);
        $this->assertSame('test@example.com', $found->getEmail());
    }

    protected function tearDown(): void
    {
        // Clean up to prevent test pollution
        $this->em->createQuery('DELETE FROM App\Entity\User')->execute();
        parent::tearDown();
    }
}

The getContainer() method returns the test container, which exposes private services for testing purposes. This differs from the runtime container where private services remain inaccessible.

Database Isolation with Transactions

Test isolation prevents data from one test affecting another. The DAMADoctrineTestBundle wraps each test in a transaction that rolls back automatically.

tests/Integration/Service/OrderServiceTest.phpphp
namespace App\Tests\Integration\Service;

use App\Entity\Order;
use App\Service\OrderService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class OrderServiceTest extends KernelTestCase
{
    private OrderService $orderService;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        self::bootKernel();
        $container = static::getContainer();
        
        $this->orderService = $container->get(OrderService::class);
        $this->em = $container->get(EntityManagerInterface::class);
    }

    public function testCreateOrderPersistsToDatabase(): void
    {
        // With DAMA bundle, this persists within a transaction
        $order = $this->orderService->create(
            customerId: 1,
            items: [['sku' => 'ABC123', 'quantity' => 2]]
        );

        // Verify persistence
        $this->em->clear();
        $persisted = $this->em->find(Order::class, $order->getId());
        
        $this->assertNotNull($persisted);
        $this->assertCount(1, $persisted->getItems());
    }
    
    // Transaction rolls back after test - no cleanup needed
}

Configure the bundle in phpunit.xml.dist by adding the extension. Each test then runs in complete isolation without manual cleanup code.

Ready to ace your Symfony interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Functional Testing with WebTestCase

Functional tests simulate HTTP requests and verify responses. WebTestCase provides a client that makes requests to controllers without starting a web server.

tests/Functional/Controller/ProductControllerTest.phpphp
namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

final class ProductControllerTest extends WebTestCase
{
    public function testListProductsReturnsJsonArray(): void
    {
        // Create client and make GET request
        $client = static::createClient();
        $client->request('GET', '/api/products');

        // Assert response status
        $this->assertResponseIsSuccessful();
        $this->assertResponseStatusCodeSame(Response::HTTP_OK);
        
        // Assert JSON structure
        $this->assertResponseHeaderSame('Content-Type', 'application/json');
        
        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertIsArray($data);
        $this->assertArrayHasKey('products', $data);
    }

    public function testCreateProductRequiresAuthentication(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/products', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['name' => 'New Product', 'price' => 29.99]));

        // Unauthenticated requests should return 401
        $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
    }
}

The $client->request() method accepts the HTTP method, URI, parameters, files, server variables, and body content. Symfony processes the request through the full kernel, including middleware, security, and routing.

Testing Authenticated Requests

For endpoints requiring authentication, Symfony's security component provides the loginUser() method on the test client.

tests/Functional/Controller/AdminControllerTest.phpphp
namespace App\Tests\Functional\Controller;

use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class AdminControllerTest extends WebTestCase
{
    public function testAdminDashboardRequiresAdminRole(): void
    {
        $client = static::createClient();
        $container = static::getContainer();
        
        // Fetch a test user with ROLE_ADMIN
        $userRepository = $container->get(UserRepository::class);
        $adminUser = $userRepository->findOneBy(['email' => 'admin@example.com']);
        
        // Authenticate the client
        $client->loginUser($adminUser);
        
        // Access protected route
        $client->request('GET', '/admin/dashboard');
        
        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h1', 'Admin Dashboard');
    }

    public function testRegularUserCannotAccessAdmin(): void
    {
        $client = static::createClient();
        $container = static::getContainer();
        
        $userRepository = $container->get(UserRepository::class);
        $regularUser = $userRepository->findOneBy(['email' => 'user@example.com']);
        
        $client->loginUser($regularUser);
        $client->request('GET', '/admin/dashboard');
        
        // Should be forbidden, not redirected to login
        $this->assertResponseStatusCodeSame(403);
    }
}

The loginUser() method sets up the security token without going through the login form, making tests faster and more focused.

Form Testing and CSRF Handling

Testing forms requires handling CSRF tokens. The crawler component extracts form elements and submits them with proper token handling.

tests/Functional/Controller/RegistrationControllerTest.phpphp
namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class RegistrationControllerTest extends WebTestCase
{
    public function testRegistrationFormSubmission(): void
    {
        $client = static::createClient();
        
        // Load the registration page
        $crawler = $client->request('GET', '/register');
        
        // Extract the form and fill fields
        $form = $crawler->selectButton('Register')->form([
            'registration_form[email]' => 'newuser@example.com',
            'registration_form[plainPassword]' => 'SecurePass123!',
            'registration_form[agreeTerms]' => true,
        ]);
        
        // Submit - CSRF token is included automatically
        $client->submit($form);
        
        // Assert redirect after successful registration
        $this->assertResponseRedirects('/login');
        
        // Follow redirect and verify flash message
        $client->followRedirect();
        $this->assertSelectorTextContains('.alert-success', 'Registration successful');
    }

    public function testRegistrationValidationErrors(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/register');
        
        // Submit with invalid email
        $form = $crawler->selectButton('Register')->form([
            'registration_form[email]' => 'not-an-email',
            'registration_form[plainPassword]' => '123', // Too short
            'registration_form[agreeTerms]' => false,
        ]);
        
        $client->submit($form);
        
        // Should stay on same page with errors
        $this->assertResponseIsSuccessful();
        $this->assertSelectorExists('.invalid-feedback');
    }
}

The selectButton() method finds the form by its submit button text. For forms without visible buttons, use selectForm() with a CSS selector.

Testing Console Commands

Commands often perform critical operations like data imports or scheduled jobs. CommandTester executes commands and captures output.

tests/Command/ImportUsersCommandTest.phpphp
namespace App\Tests\Command;

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

final class ImportUsersCommandTest extends KernelTestCase
{
    public function testImportUsersWithValidCsv(): void
    {
        self::bootKernel();
        $application = new Application(self::$kernel);
        
        $command = $application->find('app:import-users');
        $tester = new CommandTester($command);
        
        // Execute with arguments and options
        $tester->execute([
            'file' => 'tests/fixtures/users.csv',
            '--dry-run' => true,
        ]);
        
        // Assert exit code (0 = success)
        $this->assertSame(0, $tester->getStatusCode());
        
        // Assert output contains expected text
        $output = $tester->getDisplay();
        $this->assertStringContainsString('Imported 5 users', $output);
    }

    public function testImportUsersFailsWithMissingFile(): void
    {
        self::bootKernel();
        $application = new Application(self::$kernel);
        
        $command = $application->find('app:import-users');
        $tester = new CommandTester($command);
        
        $tester->execute(['file' => 'nonexistent.csv']);
        
        // Non-zero exit code indicates failure
        $this->assertSame(1, $tester->getStatusCode());
        $this->assertStringContainsString('File not found', $tester->getDisplay());
    }
}

For commands with interactive input, pass ['interactive' => false] as the second argument to execute(), or use setInputs() to simulate user responses.

Messenger Testing for Async Jobs

Testing Symfony Messenger handlers requires verifying that messages dispatch correctly and handlers process them as expected.

tests/Integration/Message/SendWelcomeEmailHandlerTest.phpphp
namespace App\Tests\Integration\Message;

use App\Message\SendWelcomeEmail;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Transport\InMemoryTransport;

final class SendWelcomeEmailHandlerTest extends KernelTestCase
{
    public function testWelcomeEmailIsDispatched(): void
    {
        self::bootKernel();
        $container = static::getContainer();
        
        $bus = $container->get(MessageBusInterface::class);
        
        // Dispatch the message
        $bus->dispatch(new SendWelcomeEmail(userId: 123));
        
        // Retrieve the in-memory transport configured in config/packages/test/messenger.yaml
        /** @var InMemoryTransport $transport */
        $transport = $container->get('messenger.transport.async');
        
        // Assert message was sent
        $this->assertCount(1, $transport->getSent());
        
        $envelope = $transport->getSent()[0];
        $message = $envelope->getMessage();
        
        $this->assertInstanceOf(SendWelcomeEmail::class, $message);
        $this->assertSame(123, $message->getUserId());
    }
}

Configure an in-memory transport in config/packages/test/messenger.yaml to capture dispatched messages without actual queue processing.

Code Coverage Configuration

PHPUnit code coverage reports identify untested code paths. Configure coverage in phpunit.xml.dist.

xml
<!-- 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"
         bootstrap="tests/bootstrap.php"
         colors="true"
         executionOrder="depends,defects"
         cacheResult="true">
    
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
        <testsuite name="Functional">
            <directory>tests/Functional</directory>
        </testsuite>
    </testsuites>
    
    <source>
        <include>
            <directory suffix=".php">src</directory>
        </include>
        <exclude>
            <directory>src/DataFixtures</directory>
            <directory>src/Migrations</directory>
        </exclude>
    </source>
    
    <coverage>
        <report>
            <html outputDirectory="var/coverage"/>
            <clover outputFile="var/coverage/clover.xml"/>
        </report>
    </coverage>
</phpunit>

Run tests with coverage using php bin/phpunit --coverage-html var/coverage. The HTML report shows line-by-line coverage status.

Conclusion

  • Use TestCase for pure unit tests with no container dependencies
  • Use KernelTestCase when tests need services from the container
  • Use WebTestCase for HTTP request simulation and response assertions
  • Configure DAMADoctrineTestBundle for automatic transaction rollback between tests
  • Test authentication with loginUser() rather than simulating login forms
  • Use CommandTester for console command verification with input/output capture
  • Configure in-memory transports in test environment for Messenger testing
  • Organize tests into Unit, Integration, and Functional directories for clarity

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#symfony
#phpunit
#testing
#php
#tdd

Share

Related articles