Symfony Testing Guide: PHPUnit, KernelTestCase, and WebTestCase

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-pack

The 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-bundle

This 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: true
use 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.

Read more

Start now free