Integration Tests
Boot the kernel in a test, pull a real service out of the container, and run a repository against a real database.
self::bootKernel(); $service = static::getContainer()->get(SomeService::class);A unit test proves one class is right. It cannot prove the pieces fit. An integration test boots the real application and asks the container for a real service, with its real dependencies wired in. Extend KernelTestCase from Symfony\Bundle\FrameworkBundle\Test.
Booting and fetching
Call self::bootKernel() to start the application, then static::getContainer() to get the container, then get() on it with the id of the service you want. No HTTP request happens, so this is the right place for anything that does not involve a controller: a repository, a mailer, a message handler, a custom validator.
The container static::getContainer() returns is a special one that exists only in the test environment. It can hand you private services, which the real container cannot. self::$kernel->getContainer() is the real one; asking it for a private service fails. Two ways to reach the entity manager read identically but go through different ids:
$em = static::getContainer()->get(EntityManagerInterface::class);
$em = static::getContainer()->get('doctrine')->getManager(); // same object
Against a real database
A repository test with a doubled connection proves nothing about your query. Point the test environment at a real database and run the query. Which rows are in it, and how they get cleared between tests, is the next lesson.
Close the entity manager in tearDown(). Without that, every test method leaves a connection and an identity map behind and a large suite runs out of memory.
Example
<?php
// tests/Repository/ArticleRepositoryTest.php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class ArticleRepositoryTest extends KernelTestCase
{
private ?EntityManagerInterface $entityManager = null;
private ?ArticleRepository $articles = null;
protected function setUp(): void
{
self::bootKernel();
// The test container hands over private services too.
$container = static::getContainer();
$this->entityManager = $container->get(EntityManagerInterface::class);
$this->articles = $container->get(ArticleRepository::class);
}
public function testFindPublishedReturnsNewestFirst(): void
{
// findPublished() joins the author in, so reading $article->getAuthor()
// below costs no extra query.
$found = $this->articles->findPublished();
$titles = array_map(
static fn (Article $article): string => $article->getTitle(),
$found,
);
self::assertSame(['Second', 'First'], $titles);
self::assertSame('[email protected]', $found[0]->getAuthor()->getEmail());
}
protected function tearDown(): void
{
parent::tearDown();
// Without this the suite leaks a connection per test method.
$this->entityManager?->close();
$this->entityManager = null;
$this->articles = null;
}
}
// Output:
// OK (1 test, 2 assertions)
When to use it
- Check that a repository method returns the rows and the order you expect.
- Verify a service is wired correctly after changing its constructor arguments.
- Test a message handler by fetching it from the container and calling it directly.
- Run a validator against a real entity with the real constraint metadata loaded.
More examples
The query the test is checking
This is the code the integration test actually covers: the DQL, the join, the parameter binding and the ordering. A doubled connection would have returned whatever the test told it to and proved none of it. Note addSelect('author') alongside the join; the join alone would still leave the author lazy and produce a query per article in the loop.
<?php
// src/Repository/ArticleRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Article>
*/
final class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
/**
* @return list<Article>
*/
public function findPublished(): array
{
return $this->createQueryBuilder('a')
// Join and select the author so a loop over the result
// does not issue one query per row.
->addSelect('author')
->join('a.author', 'author')
->andWhere('a.publishedAt <= :now')
// Bound parameter, never concatenated into the DQL.
->setParameter('now', new \DateTimeImmutable('2026-06-01 00:00:00'))
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
}
Replacing one service, keeping the rest real
set() on the test container replaces a service for this test only, which is how you keep a real container while cutting off the one dependency you must not call, such as a payment gateway. It works on shared services; for a service declared non-shared, pass a closure that returns the double instead. Services are resolved on first use, so call set() before get().
<?php
// tests/Notification/WelcomeMailerTest.php
declare(strict_types=1);
namespace App\Tests\Notification;
use App\Notification\WelcomeMailer;
use App\Pricing\TaxRateProviderInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class WelcomeMailerTest extends KernelTestCase
{
public function testTheRateIsTakenFromTheProvider(): void
{
self::bootKernel();
$container = static::getContainer();
$rates = $this->createMock(TaxRateProviderInterface::class);
$rates->method('rateFor')->willReturn(0.19);
// Swap one service in the container; everything else stays real.
$container->set(TaxRateProviderInterface::class, $rates);
$mailer = $container->get(WelcomeMailer::class);
self::assertStringContainsString('19%', $mailer->previewFor('DE'));
}
}
Pointing the test environment at a second database
Everything under config/packages/test/ applies only when the environment is test. The dbname_suffix key is what keeps app and app_test apart without a second DATABASE_URL, and the TEST_TOKEN placeholder lets parallel test processes each take their own database. The equivalent inline form is a when@test block inside config/packages/doctrine.yaml.
# config/packages/test/doctrine.yaml
doctrine:
dbal:
# Appended to the database name from DATABASE_URL, so the
# development database is never the one a test truncates.
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
Discussion