Symfony Testing Guide: PHPUnit, KernelTestCase, and WebTestCase
Symfony has one of the most complete testing stories in the PHP ecosystem. It ships with three base test classes that match different testing needs, a test HTTP client that doesn't require a running server, and deep integration with Doctrine for database testing. This guide covers the full stack from pure unit tests to HTTP integration tests.
Test Structure
Symfony distinguishes three test layers:
| Base Class | Use When |
|---|---|
TestCase (PHPUnit) |
Pure unit tests — no container, no DB |
KernelTestCase |
Service/repository tests — need container |
WebTestCase |
HTTP tests — need full request/response |
Always start with the lowest level that works. A service test doesn't need a web client. A validation test doesn't need the container.
Installation
composer require --dev phpunit/phpunit symfony/test-packThe test-pack includes the browser kit, CSS selector, and DOM crawler.
Unit Tests
For classes with no Symfony dependencies, use plain PHPUnit:
<?php
// tests/Unit/PricingCalculatorTest.php
namespace App\Tests\Unit;
use App\Service\PricingCalculator;
use PHPUnit\Framework\TestCase;
class PricingCalculatorTest extends TestCase
{
private PricingCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PricingCalculator(taxRate: 0.20);
}
public function testBasePrice(): void
{
$this->assertSame(100.0, $this->calculator->basePrice(100.0));
}
public function testPriceWithTax(): void
{
$this->assertSame(120.0, $this->calculator->withTax(100.0));
}
public function testDiscountedPrice(): void
{
$this->assertSame(80.0, $this->calculator->applyDiscount(100.0, 20));
}
/**
* @dataProvider invalidDiscountProvider
*/
public function testRejectsInvalidDiscount(int $discount): void
{
$this->expectException(\InvalidArgumentException::class);
$this->calculator->applyDiscount(100.0, $discount);
}
public static function invalidDiscountProvider(): array
{
return [
'negative discount' => [-1],
'over 100%' => [101],
];
}
}KernelTestCase — Service Tests
Use KernelTestCase when your service depends on other services or configuration from the container:
<?php
// tests/Integration/UserServiceTest.php
namespace App\Tests\Integration;
use App\Service\UserService;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class UserServiceTest extends KernelTestCase
{
private UserService $userService;
protected function setUp(): void
{
self::bootKernel(['environment' => 'test']);
$container = static::getContainer();
$this->userService = $container->get(UserService::class);
}
public function testCreatesUserWithHashedPassword(): void
{
$user = $this->userService->create([
'email' => 'alice@example.com',
'password' => 'plain-text-password',
]);
$this->assertNotNull($user->getId());
$this->assertNotSame('plain-text-password', $user->getPassword());
$this->assertTrue(password_verify('plain-text-password', $user->getPassword()));
}
}Mocking Services in the Container
protected function setUp(): void
{
self::bootKernel();
$container = static::getContainer();
// Replace a service with a mock for this test
$mailerMock = $this->createMock(MailerInterface::class);
$mailerMock->expects($this->once())
->method('send');
$container->set(MailerInterface::class, $mailerMock);
$this->userService = $container->get(UserService::class);
}WebTestCase — HTTP Tests
WebTestCase boots the kernel, creates a test HTTP client, and lets you make requests without a running server:
<?php
// tests/Controller/UserControllerTest.php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class UserControllerTest extends WebTestCase
{
public function testListUsersRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/api/users');
$this->assertResponseStatusCodeSame(401);
}
public function testListUsersReturnsJson(): void
{
$client = static::createClient();
$client->loginUser($this->getAdminUser());
$client->request('GET', '/api/users', [], [], [
'HTTP_ACCEPT' => 'application/json',
]);
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/json');
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('users', $data);
}
public function testCreateUserWithValidData(): void
{
$client = static::createClient();
$client->loginUser($this->getAdminUser());
$client->request('POST', '/api/users', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'newuser@example.com',
'name' => 'New User',
'role' => 'user',
]));
$this->assertResponseStatusCodeSame(201);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('newuser@example.com', $response['email']);
}
public function testCreateUserWithDuplicateEmailFails(): void
{
$client = static::createClient();
$client->loginUser($this->getAdminUser());
$payload = json_encode(['email' => 'existing@example.com', 'name' => 'Test']);
$client->request('POST', '/api/users', [], [], ['CONTENT_TYPE' => 'application/json'], $payload);
$client->request('POST', '/api/users', [], [], ['CONTENT_TYPE' => 'application/json'], $payload);
$this->assertResponseStatusCodeSame(422);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertStringContainsString('email', $response['message']);
}
}Form and HTML Tests
For server-rendered pages, use the Crawler:
public function testLoginFormSubmission(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/login');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('form[name="login"]');
$form = $crawler->selectButton('Log In')->form();
$form['email'] = 'alice@example.com';
$form['password'] = 'correct-password';
$client->submit($form);
$this->assertResponseRedirects('/dashboard');
$client->followRedirect();
$this->assertSelectorTextContains('h1', 'Dashboard');
}Symfony's assertSelectorExists, assertSelectorTextContains, and other CSS selector assertions make HTML testing expressive.
Database Testing with Fixtures
Use DoctrineFixturesBundle for test data:
composer require --dev doctrine/doctrine-fixtures-bundle<?php
// src/DataFixtures/UserFixtures.php
namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserFixtures extends Fixture
{
public function __construct(
private UserPasswordHasherInterface $passwordHasher
) {}
public function load(ObjectManager $manager): void
{
$admin = new User();
$admin->setEmail('admin@example.com');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin123'));
$manager->persist($admin);
$user = new User();
$user->setEmail('user@example.com');
$user->setRoles(['ROLE_USER']);
$user->setPassword($this->passwordHasher->hashPassword($user, 'user123'));
$manager->persist($user);
$this->addReference('admin-user', $admin);
$this->addReference('regular-user', $user);
$manager->flush();
}
}Load fixtures in tests using the DatabasePrimer trait from dama/doctrine-test-bundle:
composer require --dev dama/doctrine-test-bundleThis package wraps each test in a transaction that is rolled back, like Ecto's sandbox:
<?php
// config/packages/test/dama_doctrine_test.yaml
dama_doctrine_test:
enable_static_connection: true
enable_static_meta_data_cache: true
enable_static_query_cache: trueuse DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver;
class UserRepositoryTest extends KernelTestCase
{
use \DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension; // in phpunit.xml
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
}
public function testFindsByEmail(): void
{
// Fixtures are loaded once; each test rolls back changes
$user = $this->em->getRepository(User::class)
->findOneBy(['email' => 'admin@example.com']);
$this->assertNotNull($user);
$this->assertSame('ROLE_ADMIN', $user->getRoles()[0]);
}
}phpunit.xml 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"
bootstrap="tests/bootstrap.php"
colors="true">
<php>
<ini name="display_errors" value="1"/>
<env name="APP_ENV" value="test"/>
<env name="DATABASE_URL" value="postgresql://app:app@localhost/app_test"/>
<env name="KERNEL_CLASS" value="App\Kernel"/>
</php>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="Controller">
<directory>tests/Controller</directory>
</testsuite>
</testsuites>
<extensions>
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
</extensions>
<coverage>
<include>
<directory>src</directory>
</include>
</coverage>
</phpunit>Useful Assertions Reference
// HTTP response
$this->assertResponseIsSuccessful(); // 2xx
$this->assertResponseStatusCodeSame(404);
$this->assertResponseRedirects('/login');
$this->assertResponseHeaderSame('content-type', 'application/json');
// Page content
$this->assertSelectorExists('.alert-success');
$this->assertSelectorTextContains('h1', 'Dashboard');
$this->assertSelectorTextNotContains('.errors', 'required');
$this->assertInputValueSame('email', 'alice@example.com');
// Email
$this->assertEmailCount(1);
$this->assertEmailAddressContains($email, 'to', 'alice@example.com');
$this->assertEmailTextBodyContains($email, 'Welcome');Summary
Symfony's three-tier test system maps cleanly to test isolation goals: TestCase for logic that has no dependencies, KernelTestCase for services that need the container, and WebTestCase for HTTP behavior. The test HTTP client makes controller tests fast and deterministic — no browser, no network, no flakiness from async rendering. Combined with dama/doctrine-test-bundle for database rollback, a Symfony test suite can run thousands of tests per minute.