Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Hello Integration Tests!

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Hey hey, people! Welcome to episode two of our testing series, which is all about integration testing. In episode 1, Anakin accidentally triggered the auto-pilot on a star fighter... which then taught us all about unit testing! What luck!

Unt tests are the purest form of testing where you test classes and the methods on those classes. And if a class requires other classes, you mock those dependencies. It's cool and beautiful... and totally doesn't lead to the dark side, I promise.

In this tutorial, things get messier, but also more useful in the right situations! Instead of mocking dependencies, we're going to test with real live services... which sometimes means our tests will cause real things to happen, like actual queries to the database! That comes with all kinds of exciting complications! And we're going to dive into all of them.

Project Setup

But first, let's activate our own autopilot and get our app going! Testing is fun, so download the course code from this page and code along with me. After you unzip the file, you'll find a start/ directory with the same code that you see here, including this nifty README.md file. This has all the setup instructions, including database setup, because we do have a database in this course. If you were with us for episode one - welcome back - and be sure to download this course code because we've changed a few things, like adding a database and upgrading some dependencies.

Oh, and this tutorial uses PHPUnit 9, even though PHPUnit 10 is out. That's fine because there aren't many user-facing changes in PHPUnit 10.

The last step in the README is to find your terminal, move into the project, and run

symfony serve -d

to start a local web server at Click that and... here we are! Dinotopia: The app where we get to see the status of the dinosaurs inside our park. And now, these dinosaurs are coming from the database. It's not fancy, but we have a Dinosaur entity. And inside our one controller, we query for all the dinosaurs... and that's what we pass into the template... which is what we see here.

Checking for a "Lock Down"

Everything with the app is working great. Well... except for that one, minor problem. You see, sometimes Big Eaty (that's our resident T-Rex) escapes, and we don't have a way to lock down the park and notify people. Basically, management is worried that too many guests are being eaten. So the first feature we're going to build is a system to initiate a lockdown... and we already have an entity for this! It's called, creatively, LockDown... with $createdAt, $endedAt, and $status (which is an Enum). Inside the Enum, there are three cases: ACTIVE, ENDED, or RUN_FOR_YOUR_LIFE. Let's... try to avoid that last one...

... lines 1 - 4
use App\Enum\LockDownStatus;
... lines 6 - 10
class LockDown
... lines 13 - 17
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $endedAt = null;
#[ORM\Column(type: Types::STRING, enumType: LockDownStatus::class)]
private ?LockDownStatus $status = LockDownStatus::ACTIVE;
... lines 26 - 84

... lines 1 - 4
enum LockDownStatus: string
case ACTIVE = 'active';
case ENDED = 'ended';
case RUN_FOR_YOUR_LIFE = 'run_for_your_life';

On our MainController (our homepage), if the most recent lockdown record in the database has an ACTIVE or RUN_FOR_YOUR_LIFE status, we need to render a giant warning message on the screen.

... lines 1 - 12
class MainController extends AbstractController
#[Route(path: '/', name: 'app_homepage', methods: ['GET'])]
public function index(GithubService $github, DinosaurRepository $repository): Response
$dinos = $repository->findAll();
foreach ($dinos as $dino) {
return $this->render('main/index.html.twig', [
'dinos' => $dinos,
... lines 28 - 37

To help with this, open src/Repository/LockDownRepository.php. To figure out if we're in a lockdown, add a new method called isInLockDown() which will return a bool. For now, just return false.

... lines 1 - 16
class LockDownRepository extends ServiceEntityRepository
... lines 19 - 23
public function isInLockDown(): bool
return false;

Creating the Test

Let's use some test driven development! Before we write this query, let's add a test for it. We don't have a test for the LockDownRepository class yet, so open tests/. In the first tutorial, we created a Unit/ directory and matched the directory structure inside of src/ for all the classes we need to test.

This time, create a directory called Integration/. You don't need to organize things like this, but it's fairly common to have unit tests in one directory and integration tests in another. We haven't talked about what an integration test is yet, but we'll see that in a minute.

Inside of Integration/, we're still going to follow the directory structure. Create a Repository/ directory since this class lives in src/Repository/... and inside, a new PHP class called LockDownRepositoryTest.

Start like we always do: extend TestCase from PHPUnit. Call the first method testIsInLockDownWithNoLockdownRows(). This will test that, if the lockdown table is empty, then the method should return false.

... lines 1 - 6
class LockDownRepositoryTest extends TestCase
public function testIsInLockDownWithNoLockDownRows()

Ok, let's keep pretending that we're living in the world of unit testing and try to test this... like we did in the previous tutorial. To do that, say $repository = new LockDownRepository().

Uh Oh, Instantiating this Object is Hard!

But, hmm. LockDownRepository extends ServiceEntityRepository, which extends another class from Doctrine. If you look, to instantiate it, we need to pass a ManagerRegistry from Doctrine. And if you hold "command" or "control" and click into this... and go to the base class, it gets complicated. It calls $registry->getManagerForClass() to get the entity manager... and it passes that to the parent. So already, we're going to need to mock the registry... so that when getManagerForClass() is called, it returns a mocked entity manager.

Inside our repository, we will eventually call $this->createQueryBuilder(). If you dive into that, it uses the _em property (that's that entity manager that we're planning to mock) and calls createQueryBuilder(), which returns a QueryBuilder. So we also need to mock this method on EntityManager to return a mock QueryBuilder.

This is getting crazy! We have a mock, to return a mock, to return another mock. And ultimately, what would we assert? Would we assert that our code calls the ->andWhere() method on QueryBuilder with the correct arguments? Or are we going to... somehow have the QueryBuilder generate a real query string... then assert that the string... looks correct to us?

Why A Unit Test is the Wrong Tool

No: we're going to do none of that. What we're seeing is a situation where a unit test is not the right tool. And there are two reasons. First, it's too complex! Creating a unit test will require a seemingly never-ending series of mocks. And second, a unit test wouldn't be useful! If we're creating a complex query inside of LockDownRepository, to make that a truly useful test, we need to actually execute that query and make sure it returns the results we expect from the database.

So, instead of creating a fresh LockDownRepository with a bunch of mocks, we're going to ask Symfony to give us the real LockDownRepository: the one that we would use in our normal code. The one that, when we call a method on it from our test, will execute a real query to the database.

That's called an "integration test", and I'll show you how to do it next.

Leave a comment!

Login or Register to join the conversation
Vaceslav-L Avatar
Vaceslav-L Avatar Vaceslav-L | posted 23 days ago

Hello. Maybe you can explain how to mock "cache" with different pools in functional tests?


Hey @Vaceslav-L

An easy way to mock the cache service is to create a mock from the cache interface CacheItemPoolInterface (I believe that's the one), and then you can add a few assertions on the methods you expect to be called. The only downside is that you may need to mock the return value of the getItem() method.


Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses PHPUnit 9 but works just fine for PHPUnit 10.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "doctrine/doctrine-bundle": "^2.8", // 2.10.2
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
        "doctrine/orm": "^2.14", // 2.16.2
        "symfony/asset": "6.3.*", // v6.3.0
        "symfony/console": "6.3.*", // v6.3.4
        "symfony/dotenv": "6.3.*", // v6.3.0
        "symfony/flex": "^2", // v2.3.3
        "symfony/framework-bundle": "6.3.*", // v6.3.5
        "symfony/http-client": "6.3.*", // v6.3.5
        "symfony/mailer": "6.3.*", // v6.3.5
        "symfony/messenger": "6.3.*", // v6.3.5
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/runtime": "6.3.*", // v6.3.2
        "symfony/security-csrf": "6.3.*", // v6.3.2
        "symfony/twig-bundle": "6.3.*", // v6.3.0
        "symfony/yaml": "6.3.*" // v6.3.3
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
        "phpunit/phpunit": "^9.5", // 9.6.13
        "symfony/browser-kit": "6.3.*", // v6.3.2
        "symfony/css-selector": "6.3.*", // v6.3.2
        "symfony/debug-bundle": "6.3.*", // v6.3.2
        "symfony/maker-bundle": "^1.48", // v1.51.1
        "symfony/phpunit-bridge": "^6.2", // v6.3.2
        "symfony/stopwatch": "6.3.*", // v6.3.0
        "symfony/web-profiler-bundle": "6.3.*", // v6.3.2
        "zenstruck/foundry": "^1.35", // v1.35.0
        "zenstruck/mailer-test": "^1.3", // v1.3.0
        "zenstruck/messenger-test": "^1.7" // v1.7.3