Test Data and Isolation

Give tests their own database, load known rows with fixtures, and reset state so no test depends on another running first.

Syntaxphp bin/console --env=test doctrine:fixtures:load --no-interaction

A test that reads the database needs to know what is in it. Two problems follow: getting known rows in, and getting them out again before the next test.

A database of its own

Never point tests at your development database. Set DATABASE_URL in .env.test to a separate name, create it once with php bin/console --env=test doctrine:database:create, then build the schema by running your migrations against it with --env=test. Running migrations rather than doctrine:schema:create also tests the migrations.

Fixtures

A fixture is a class that writes a known set of rows. Install doctrine/doctrine-fixtures-bundle, extend Doctrine\Bundle\FixturesBundle\Fixture, and build entities in load(ObjectManager $manager). persist() schedules an object; flush() writes everything scheduled. A fixture that never flushes saves nothing.

Fixtures that need each other's rows share them by reference: $this->addReference('editor', $user) in one, $this->getReference('editor', User::class) in another, with DependentFixtureInterface::getDependencies() fixing the load order.

Resetting between tests

Reloading fixtures before every test method is correct and slow. dama/doctrine-test-bundle is the usual answer: registered as a PHPUnit extension, it opens a transaction before each test and rolls it back afterwards, so writes vanish without a reload.

Why order matters

If test B passes only because test A created a row, the suite is lying. It breaks the day someone reorders a file, runs one test alone, or turns on parallel execution. Each test must set up what it needs and assume nothing else ran.

Example

Example · php
<?php
// src/DataFixtures/ArticleFixtures.php

declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\Article;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;

final class ArticleFixtures extends Fixture implements DependentFixtureInterface
{
    public function load(ObjectManager $manager): void
    {
        // The row UserFixtures created, fetched by the name it registered.
        $author = $this->getReference(UserFixtures::EDITOR, User::class);

        // Fixed dates, so an assertion on ordering is the same every run.
        $dates = [
            'First'  => '2026-01-01 09:00:00',
            'Second' => '2026-02-01 09:00:00',
        ];

        foreach ($dates as $title => $publishedAt) {
            $article = new Article();
            $article->setTitle($title);
            $article->setAuthor($author);
            $article->setPublishedAt(new \DateTimeImmutable($publishedAt));

            // persist() only schedules the insert.
            $manager->persist($article);
        }

        // flush() is what actually writes. Without it, nothing is saved.
        $manager->flush();
    }

    /**
     * @return list<class-string>
     */
    public function getDependencies(): array
    {
        return [UserFixtures::class];
    }
}

When to use it

  • Create a test database and schema from scratch in a CI job.
  • Give every test the same two users and two articles to assert against.
  • Stop a test suite from failing when PHPUnit runs the files in a different order.
  • Speed up a slow repository suite by rolling back instead of reloading fixtures.

More examples

The fixture that owns the shared user

Fixtures are services, so dependencies arrive through the constructor; here that is the password hasher, because a user whose password is stored unhashed cannot log in and a login test would fail for the wrong reason. The reference constants are what let ArticleFixtures attach articles to a known author without querying for it.

Example · php
<?php
// src/DataFixtures/UserFixtures.php

declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class UserFixtures extends Fixture
{
    // A constant, not a loose string, so a typo is a compile-time problem.
    public const EDITOR = 'user-editor';
    public const ADMIN = 'user-admin';

    public function __construct(
        private readonly UserPasswordHasherInterface $hasher,
    ) {
    }

    public function load(ObjectManager $manager): void
    {
        $people = [
            self::EDITOR => ['[email protected]', ['ROLE_EDITOR']],
            self::ADMIN  => ['[email protected]', ['ROLE_ADMIN']],
        ];

        foreach ($people as $reference => [$email, $roles]) {
            $user = new User();
            $user->setEmail($email);
            $user->setRoles($roles);
            $user->setPassword($this->hasher->hashPassword($user, 'test-password'));

            $manager->persist($user);

            // Let other fixtures ask for this exact row.
            $this->addReference($reference, $user);
        }

        $manager->flush();
    }
}

Wiring the test database and the rollback

Running migrations against the test database instead of doctrine:schema:create means a broken migration fails your suite rather than production. These three commands belong in a script your CI job calls, so a fresh checkout reaches a usable state with one step. Note the purge behaviour: doctrine:fixtures:load is destructive by default.

Example · bash
# .env.test

# A different database name from development. The suffix is the point.
DATABASE_URL="postgresql://app:[email protected]:5432/app_test?serverVersion=17&charset=utf8"

# ---------------------------------------------------------------
# One-time setup, and after any new migration:
#
#   php bin/console --env=test doctrine:database:create --if-not-exists
#   php bin/console --env=test doctrine:migrations:migrate --no-interaction
#   php bin/console --env=test doctrine:fixtures:load --no-interaction
#
# doctrine:fixtures:load purges every table first unless you pass
# --append. That is why it must never run against development.
# ---------------------------------------------------------------

Rolling back instead of reloading

With this extension registered you load fixtures once, then every test starts from that same state and its writes are discarded. The fail-on flags are worth turning on at the same time: they make a deprecation notice break the build while the upgrade is cheap, instead of surfacing as a fatal error on the next major version.

Example · xml
<!-- phpunit.dist.xml -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         cacheDirectory=".phpunit.cache"
         failOnDeprecation="true"
         failOnNotice="true"
         failOnWarning="true">

    <!-- Wraps every test in a transaction and rolls it back afterwards.
         Install with: composer require --dev dama/doctrine-test-bundle -->
    <extensions>
        <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
    </extensions>

    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

Discussion

  • Be the first to comment on this lesson.