# 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. - Published: 2026-07-15 - Updated: 2026-07-15 - Author: SharpSkill - Tags: symfony, phpunit, testing, php, tdd - Reading time: 12 min --- 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. ```php // tests/Unit/Service/PriceCalculatorTest.php 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. ```php // tests/Integration/Repository/UserRepositoryTest.php 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](https://github.com/dmaicher/doctrine-test-bundle) wraps each test in a transaction that rolls back automatically. ```php // tests/Integration/Service/OrderServiceTest.php 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. ## 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. ```php // tests/Functional/Controller/ProductControllerTest.php 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](https://symfony.com/doc/current/security.html) provides the `loginUser()` method on the test client. ```php // tests/Functional/Controller/AdminControllerTest.php 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. ```php // tests/Functional/Controller/RegistrationControllerTest.php 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. ```php // tests/Command/ImportUsersCommandTest.php 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](/technologies/symfony/interview-questions/messenger) handlers requires verifying that messages dispatch correctly and handlers process them as expected. ```php // tests/Integration/Message/SendWelcomeEmailHandlerTest.php 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](https://docs.phpunit.de/en/12.0/code-coverage.html) reports identify untested code paths. Configure coverage in `phpunit.xml.dist`. ```xml tests/Unit tests/Integration tests/Functional src src/DataFixtures src/Migrations ``` 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](https://github.com/dmaicher/doctrine-test-bundle) 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 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/symfony/symfony-testing-phpunit-kerneltestcase-functional-tests