Testing It

Three layers of test for the capstone: a rule in isolation, a service with the container, and a secured page through the client.

Syntax$client = static::createClient(); $client->loginUser($user);

The capstone gets three kinds of test, and the difference between them is how much of the framework each one starts.

Three layers

A unit test extends PHPUnit's TestCase and boots nothing. It is for rules that are pure PHP: a publishing rule on an entity, a formatter, a value object. It runs in milliseconds, so there can be many.

A kernel test extends KernelTestCase, boots the container and nothing else. Use it when the thing under test needs real services wired together but no HTTP: a repository query, a validator, a message handler.

A functional test extends WebTestCase and drives a client that sends requests through the kernel in the same process. No web server runs and nothing is sent over a socket. Assertions like assertResponseIsSuccessful() and assertSelectorTextContains() inspect the response and its HTML.

Covering one secured path

$client->loginUser($user) puts an authenticated token in the session directly, so the next request arrives as that user. That is the short way to test authorization. Longhand - submitting the real login form - is what proves authentication itself works, and the capstone keeps exactly one test doing it.

What trips people up

Tests that share a database. Each test should start from a known state, either by wrapping every test in a transaction that is rolled back or by rebuilding the schema before the suite. A test suite whose result depends on the order tests ran in is worse than no suite.

Example

Example · php
<?php
// tests/Controller/ArticleControllerTest.php

declare(strict_types=1);

namespace App\Tests\Controller;

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

final class ArticleControllerTest extends WebTestCase
{
    public function testTheListIsPublic(): void
    {
        $client = static::createClient();
        $client->request('GET', '/articles');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h1', 'Articles');
    }

    public function testWritingRequiresLogin(): void
    {
        $client = static::createClient();
        $client->request('GET', '/articles/new');

        // The firewall sends an anonymous visitor to the login form.
        $this->assertResponseRedirects('/login');
    }

    public function testAnAuthorCanCreateAnArticle(): void
    {
        $client = static::createClient();

        // getContainer() is the test container: private services included.
        $users = static::getContainer()->get(UserRepository::class);
        $author = $users->findOneBy(['email' => '[email protected]']);
        self::assertInstanceOf(User::class, $author);

        $client->loginUser($author);
        $client->request('GET', '/articles/new');
        $this->assertResponseIsSuccessful();

        // Field names come from the form's block prefix, article_form.
        $client->submitForm('Save', [
            'article_form[title]' => 'Shipping day',
            'article_form[body]' => 'It went out at nine.',
        ]);

        $this->assertResponseRedirects('/articles');
    }
}

When to use it

  • Prove an author cannot open another author's edit page, as a test rather than by clicking
  • Catch a broken repository query before it reaches a page
  • Set up a test database and suite commands that CI can run unchanged

More examples

The rule, tested with nothing booted

No kernel, no database, no container: the rule lives on the entity, so the test is two objects and an assertion. Note the fixed date - a test written with new DateTimeImmutable() and no argument passes today and fails at a month boundary. Put rules here whenever you can, because this is the layer you can afford to run on every keystroke.

Example · php
<?php
// tests/Entity/ArticleTest.php

declare(strict_types=1);

namespace App\Tests\Entity;

use App\Entity\Article;
use PHPUnit\Framework\TestCase;

final class ArticleTest extends TestCase
{
    public function testPublishingRecordsTheDate(): void
    {
        $article = new Article();
        $article->setTitle('Shipping day');

        // A fixed instant, so the assertion cannot drift.
        $article->publish(new \DateTimeImmutable('2026-01-01 09:00:00'));

        self::assertTrue($article->isPublished());
        self::assertSame(
            '2026-01-01 09:00',
            $article->getPublishedAt()?->format('Y-m-d H:i'),
        );
    }

    public function testPublishingTwiceIsRejected(): void
    {
        $article = new Article();
        $article->publish(new \DateTimeImmutable('2026-01-01 09:00:00'));

        $this->expectException(\DomainException::class);
        $article->publish(new \DateTimeImmutable('2026-01-02 09:00:00'));
    }
}

A query tested against the real container

This is the layer that catches a wrong DQL condition, which a unit test cannot see and a functional test reports only as a strange page. The explicit transaction keeps the test from leaving rows behind for the next one. Use it for repositories, validators and message handlers.

Example · php
<?php
// tests/Repository/ArticleRepositoryTest.php

declare(strict_types=1);

namespace App\Tests\Repository;

use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class ArticleRepositoryTest extends KernelTestCase
{
    public function testOnlyPublishedArticlesAreListed(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        $entityManager = $container->get(EntityManagerInterface::class);
        // Every change made here is undone when the test ends.
        $entityManager->getConnection()->beginTransaction();

        try {
            $repository = $container->get(ArticleRepository::class);
            $articles = $repository->findPublishedWithAuthor();

            foreach ($articles as $article) {
                self::assertNotNull($article->getPublishedAt());
            }
        } finally {
            $entityManager->getConnection()->rollBack();
        }
    }
}

Preparing the test database and running the suite

The test environment reads config/packages/test and its own .env.test, so DATABASE_URL there should point at a separate database you are willing to drop. Running migrations rather than creating the schema from the mapping also tests the migrations themselves. These are the commands CI runs, so keep them in one script.

Example · bash
# Create the schema the test environment will use.
$ php bin/console --env=test doctrine:database:create --if-not-exists
$ php bin/console --env=test doctrine:migrations:migrate --no-interaction

# Run everything.
$ php bin/phpunit

# PHPUnit 12.0.0 by Sebastian Bergmann and contributors.
#
# Testing
# .......                                                             7 / 7 (100%)
#
# Time: 00:01.902, Memory: 42.00 MB
#
# OK (7 tests, 19 assertions)

# One file, or one test, while you work on it.
$ php bin/phpunit tests/Controller/ArticleControllerTest.php
$ php bin/phpunit --filter testAnAuthorCanCreateAnArticle

Discussion

  • Be the first to comment on this lesson.