Integration Tests

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Isn't mocking awesome? Yes! Except... when it's not. In a unit test, we use mocking so that each class can be tested in complete isolation: no database, no API calls and no friendly dinner parties. But sometimes... if you mock everything... there's nothing really left to test

For example, how would you test that a complex query in a Doctrine repository works? If you mock the database connection then... I guess you could test that the query string you wrote looks ok? That's silly! The only way to truly test this method is to run that query against a real database.

Here's the deal: sometimes, when you think about testing a class, you start to realize that if you mock all the dependencies... then the test becomes worthless! In these cases, you need an integration test.

Setting up the Database

Let's jump in! EnclosureBuilderService already has a unit test. But since it talks to the database, if we really want to make sure it works, we need a test where it... actually talks to the database!

First, we need to finish our entities. Find Security and copy the id field. Open Dinosaur and paste this in. Do the same for Enclosure. We haven't needed these yet because we haven't touched the database at all.

... lines 1 - 10
class Dinosaur
{
... lines 13 - 15
/**
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
* @ORM\Column(type="integer")
*/
private $id;
... lines 22 - 80
}

... lines 1 - 14
class Enclosure
{
/**
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
* @ORM\Column(type="integer")
*/
private $id;
... lines 23 - 88
}

Now, go to your terminal and create the database:

php bin/console doctrine:database:create

Huh, I already have one. Lucky me! Create the schema:

php bin/console doctrine:schema:create

Hello Integration Test

Now to the integration test! Create a new class: EnclosureBuilderServiceIntegrationTest. I don't always create a separate class for integration tests: it's up to you. Unit tests and integration tests can actually live next to each other in the same test class. Unlike herbivore and carnivore dinsosaurs who should really each get their own enclosure.

This time, instead of TestCase, extend KernelTestCase.

... lines 1 - 2
namespace Tests\AppBundle\Service;
... lines 4 - 7
class EnclosureBuilderServiceIntegrationTest extends KernelTestCase
... lines 9 - 18

This is not that crazy: KernelTestCase itself extends TestCase: so we have all the normal methods. But it also has a few new methods to help us boot Symfony's container. And that will give us access to our real services.

Add the test method: public function testItBuildsEnclosureWithDefaultSpecifications():

... lines 1 - 7
class EnclosureBuilderServiceIntegrationTest extends KernelTestCase
{
public function testItBuildsEnclosureWithDefaultSpecifications()
{
... lines 12 - 15
}
}

Hmm, that's a big name!

Booting & Fetching the Container

Here is the key difference between a unit test and an integration test: instead of creating the EnclosureBuilderService and passing in mock dependencies, we'll boot Symfony's container and ask it for the EnclosureBuilderService. And of course, that will be configured to talk to our real database. This makes integration tests less "pure" than unit tests: if an integration tests fails, the problem could live in multiple different places - not just in this class. And also, integration tests are way slower than unit tests. Together, this makes them less hipster than unit tests. Despite my love for being hipster, I'll concede that integration tests are really helpful.

To use the real services, first call self::bootKernel() to... um... boot Symfony's "kernel": its "core". Now we can say $enclosureBuilderService = self::$kernel->getContainer()->get() and the service's id: EnclosureBuilderService::class.

... lines 1 - 7
class EnclosureBuilderServiceIntegrationTest extends KernelTestCase
{
public function testItBuildsEnclosureWithDefaultSpecifications()
{
self::bootKernel();
$enclosureBuilderService = self::$kernel->getContainer()
->get(EnclosureBuilderService::class);
}
}

But before we do anything else... there's a surprise! Find your terminal and run phpunit with --filter. Copy the method's name and paste it:

./vendor/bin/phpunit --filter testItBuildsEnclosureWithDefaultSpecifications

Fetching Private Services

Woh! It explodes!

You have requested a non-existent service AppBundle\Service\EnclosureBuilderService.

That's weird because... in app/config/services.yml, we're using the service auto-registration code from Symfony 3.3, which registers each class as a service... and uses the class name as the service id. So why does it say the service isn't found?

Because... all services are private thanks to the public: false. This is actually very important to how Symfony works - you can learn more about it in our Symfony 3.3 tutorial. But the point is, when a service is public: false, it means that you cannot fetch it directly from the container. Normally, that's no problem! We use dependency injection everywhere. Well... everywhere except our tests.

How do we fix this? Open app/config/config_test.yml. In Symfony 4, you should open or create config/services_test.yaml. Add the services key and use _defaults below with public: true.

... lines 1 - 3
services:
_defaults:
public: true
... lines 7 - 23

Then, we're going to create a service alias. Back in the test, copy the entire class name - which is the service id. Over in config_test.yml, add test. and then paste. Set this to @ and paste again.

... lines 1 - 3
services:
... lines 5 - 7
test.AppBundle\Service\EnclosureBuilderService: '@AppBundle\Service\EnclosureBuilderService'
... lines 9 - 23

This creates a public alias: even though the original service is private, we can use this new test. service id to fetch our original service out of the container.

Try it! Back in the test, inside get(), add test. and then the class name.

... lines 1 - 7
class EnclosureBuilderServiceIntegrationTest extends KernelTestCase
{
public function testItBuildsEnclosureWithDefaultSpecifications()
{
... lines 12 - 13
$enclosureBuilderService = self::$kernel->getContainer()
->get('test.'.EnclosureBuilderService::class);
}
}

Move over and try the test again!

./vendor/bin/phpunit --filter testItBuildsEnclosureWithDefaultSpecifications

Ha! It works! It shows "Risky" because we don't have any assertions. But it did not blow up.

Adding the Database Assertions

Let's finish the thing! Above the variable, I'll add some inline documentation so that PhpStorm gives me auto-completion. Now, call the ->buildEnclosure() method. We'll use the default arguments. That should create 1 Security and 3 Dinosaur entities.

... lines 1 - 10
class EnclosureBuilderServiceIntegrationTest extends KernelTestCase
{
public function testItBuildsEnclosureWithDefaultSpecifications()
{
... lines 15 - 19
$enclosureBuilderService->buildEnclosure();
... lines 21 - 41
}
}

And... yea! All we need to do now is count the results in the database to make sure they're correct! First, fetch the EntityManager with self::$kernel->getContainer() then ->get('doctrine')->getManager(). I'll also add inline phpdoc above this to help code completion.

... lines 1 - 12
public function testItBuildsEnclosureWithDefaultSpecifications()
{
... lines 15 - 21
/** @var EntityManager $em */
$em = self::$kernel->getContainer()
->get('doctrine')
->getManager();
... lines 26 - 41
}
... lines 43 - 44

To count the results, I'll paste in some code: this accesses the Security repository, counts the results and calls getSingleScalarResult() to return just that number. After this, use $this->assertSame() to assert that 1 will match $count. If they don't match, then the "Amount of security systems is not the same". And you should look over your shoulder for escaped raptors!

... lines 1 - 12
public function testItBuildsEnclosureWithDefaultSpecifications()
{
... lines 15 - 26
$count = (int) $em->getRepository(Security::class)
->createQueryBuilder('s')
->select('COUNT(s.id)')
->getQuery()
->getSingleScalarResult();
$this->assertSame(1, $count, 'Amount of security systems is not the same');
... lines 34 - 41
}
... lines 43 - 44

Copy all of that and repeat for Dinosaur. Change the class name, and I'll change the alias to be consistent. Update the message to say "dinosaurs" and this time - thanks to the default arguments in buildEnclosure() - there should be 3.

... lines 1 - 12
public function testItBuildsEnclosureWithDefaultSpecifications()
{
... lines 15 - 34
$count = (int) $em->getRepository(Dinosaur::class)
->createQueryBuilder('d')
->select('COUNT(d.id)')
->getQuery()
->getSingleScalarResult();
$this->assertSame(3, $count, 'Amount of dinosaurs is not the same');
}
... lines 43 - 44

Ok team! We're done! Try the test!

./vendor/bin/phpunit --filter testItBuildsEnclosureWithDefaultSpecifications

It works! We're geniuses! Nothing could ever go wrong! Run the test again:

./vendor/bin/phpunit --filter testItBuildsEnclosureWithDefaultSpecifications

It fails! Suddenly there are 2 security systems in the database! And each time you execute the test, we have more: 3, 4, 5! It's easy to see what's going on: each test adds more and more stuff to the database.

As soon as you talk to the database, you have a new responsibility: you need to control exactly how the database looks.

Let's talk about that next.

Leave a comment!

  • 2020-06-04 Victor Bocharsky

    Hey Yvon,

    That's a good strategy to focus on important features first 👍

    Cheers!

  • 2020-06-04 yvon Huynh

    well if it can save me some big stress, I would not hesitate, I don't have much time so I will focus on the most important tests

  • 2020-06-04 Victor Bocharsky

    Hey Yvon,

    You're welcome! But keep in mind that it may be more resource consuming, as you would need to send queries to the DB, and it takes some time, so your tests may become slower. But as always, look for a good balance. Just don't blindly test everything :) Though, if your project is small, or you just want to practice testing, or this feature is really important in your application - why not to test it ;)

    Cheers!

  • 2020-06-04 yvon Huynh

    nice to hear that solution, thanks !

  • 2020-06-04 Victor Bocharsky

    Hey Yvon,

    It depends, usually devs do not test setters, but if we're talking about form validation - it makes sense. But usually testing that form is valid and entity is actually created in the DB is a better choice, because you may forget to add some validation constraints and form will pass validation but the DB query is failed because of SQL validation constraint.

    Cheers!

  • 2020-06-04 yvon Huynh

    How would you test form submission? I mean only test the entity is created correctly, or waiting entity insertion into DB then fetch it back and compare, thanks.

  • 2020-04-08 Diego Aguiar

    Hey Capucine Chamin

    You're 100% correct. I'll fix that ASAP

    Thanks for reporting it. Thanks!

  • 2020-04-08 Capucine Chamin

    Hello,

    I think a single quote "`" is missing in the script after the word "Security" in:

    Find `Security and copy the id field. Open Dinosaur and paste this in. Do the same for Enclosure`.

  • 2019-09-26 weaverryan

    Hey Arne K. Haaje !

    To fix the problem of EnclosureBuilderServer "mixing" carnivorous and non-carnivorous dinosaurs, we made a small tweak to the EnclosureBuilderService::addDinosaur() method. Basically, we just moved the $diet = line up a few lines so that when we add the for loop later, this part is *not* in the loop. In the tutorial, we copy the original EnclosureBuilderService from a tutorial/ directory, and if you download the course code, you'll get the updated version. You can also see the updated version here - https://symfonycasts.com/sc... (and if it's helpful, you can see the diff here: https://github.com/knpunive....

    After we add the for loop, the final product looks like this: https://symfonycasts.com/sc...

    Let me know if that helps! It was a silly detail we missed, and we don't want it to cause any real issues!

    Cheers!

  • 2019-09-24 Arne K. Haaje

    What is the correct way to avoid this error, without creating a new one?

  • 2019-07-29 Diego Aguiar

    Hey avknor

    Oh, yes, you are right, this line is causing the troubles


    // EnclosureBuilderService
    ...
    66 $diet = $diets[array_rand($diets)];
    ...

    we are going to add a note about it. Thanks for informing us about that pesky bug :)

    Cheers!

  • 2019-07-28 avknor

    Hi!
    When I run last test, I get this result

    1) Tests\AppBundle\Service\EnclosureBuilderServiceIntegrationTest::testItBuildsEnclosureWithDefaultSpecifications
    AppBundle\Exception\NotABuffetException: Please do not mix the carnivorous and non-carnivorous dinosaurs. It will be a massacre!

    When I repeat this test several times, it sometimes passes.

    This is because we generate random dinosaurs at EnclosuBuilderService->addDinosaurs().

    Bug?

  • 2019-07-05 Diego Aguiar

    Cool! So you already know how to test an API integration :)
    BTW, not all API's comes with a sandbox, it depends on the third party platform

    Cheers!

  • 2019-07-05 Felipe Luchi

    Hi Diego Aguiar , thanks for your reply.

    If I'm not mistaken, I've tested mocking API response and definitely tested hitting the production API.
    But never tested using a sandbox, I'll have a look at it anyway.

    cheers

  • 2019-07-04 Diego Aguiar

    Hey Felipe Luchi

    When you are testing an API integration there are a couple of ways you can do it and it depends on your needs
    - Mock the API response. In this case you don't hit the API but you test all the scenarios, when the response was successful, when there was an error, etc.
    - Some API's comes with a sandbox, in that case it's totally fine to hit the sandbox and your test will behave almost as in production (I say almost because I've seen some sandboxes that doesn't behave exactly the same as production)
    - Hit production API endpoints but using a different account. This some times is useful when the integration to the API is super critical for your application.

    Cheers!

  • 2019-07-04 Felipe Luchi

    I've got a question but not sure if this is the correct lesson to do it. Here it goes.
    Integration tests you test the relation between the DB and you code. So, you kind of picture the database for each specific test, it does make sense and it's all right.

    What about API? what should I test when I'm using an API on my code?
    My current approach is call the API properly, parse and test the content.
    Does it sound it right?

    thanks

  • 2019-05-22 Zacarias Calabria

    Another option, perhaps the most sensible, is to use the same version of phpunit as the tutorial, the 6.3

  • 2019-05-22 Zacarias Calabria

    I updated symfony to 3.4 and phpunit-bridge to ^3.3@dev, with php on 7.1 and phpunit to 8.1, and now it works for me correctly.

  • 2019-02-20 Victor Bocharsky

    Hey Dennis,

    It looks like an easy fix, you just need to match the signature of parent TestCase::tearDown() method in our KernelTestCase::tearDown(). You need to tweak the method signature to:


    protected function tearDown(): void
    {
    // ...
    }

    as you can see here: https://github.com/sebastia...

    Cheers!

  • 2019-02-20 Dennis Nichi

    I get this error when I am running this test. Can you suggest what is wrong?

    Fatal error: Declaration of Symfony\Bundle\FrameworkBundle\Test\KernelTestCase::tearDown() must be compatible with PHPUnit\Framework\TestCase::tearDown(): void in /var/shared/app/vendor/symfony/symfony/src/Symfony/Bundle/FrameworkBundle/Test/KernelTestCase.php on line 221

  • 2017-12-25 weaverryan

    Yo Shaun!

    Ah, this was TRICKY! You're not going to like the answer.... :D. Because the *tiniest* bugs are the hardest to find. Btw, thanks for posting your code - it was the only way I could find the small typo! Here it is - in Enclosure.php:


    /**
    * @var Collection|Security[]
    - * @ORM\OneToMany(targetEntity="AppBundle\Entity\Enclosure", mappedBy="enclosure", cascade={"persist"})
    + * @ORM\OneToMany(targetEntity="AppBundle\Entity\Security", mappedBy="enclosure", cascade={"persist"})
    */
    private $securities;

    Yep... it was simply that the relationship was mapped to expect a collection of Enclosure, not a collection of Security objects. Doctrine checks exactly for this, but the exception it throws is honestly not quite as clear as it could be. Hopefully this unblocks you :D.

    Cheers!

  • 2017-12-22 Shaun

    Hi,

    I get the following error when I am running this test, and I can't figure it out!

    vagrant@phpunit:~/code/phpunit$ ./vendor/bin/phpunit --filter testItBuildsEnclosureWithDefaultSpecification
    PHPUnit 6.5.5 by Sebastian Bergmann and contributors.

    E 1 / 1 (100%)

    Time: 3.66 seconds, Memory: 26.00MB

    There was 1 error:

    1) Tests\AppBundle\Service\EnclosureBuilderServiceIntegrationTest::testItBuildsEnclosureWithDefaultSpecification
    Doctrine\ORM\ORMInvalidArgumentException: Expected value of type "Doctrine\Common\Collections\Collection|array" for association field "AppBundle\Entity\Enclosure#$securities", got "AppBundle\Entity\Security" instead.

    /home/vagrant/code/phpunit/vendor/doctrine/orm/lib/Doctrine/ORM/ORMInvalidArgumentException.php:206
    /home/vagrant/code/phpunit/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php:840
    /home/vagrant/code/phpunit/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php:740
    /home/vagrant/code/phpunit/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php:452
    /home/vagrant/code/phpunit/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php:765
    /home/vagrant/code/phpunit/vendor/doctrine/orm/lib/Doctrine/ORM/UnitOfWork.php:340
    /home/vagrant/code/phpunit/vendor/doctrine/orm/lib/Doctrine/ORM/EntityManager.php:356
    /home/vagrant/code/phpunit/src/AppBundle/Service/EnclosureBuilderService.php:43
    /home/vagrant/code/phpunit/tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php:23

    ERRORS!
    Tests: 1, Assertions: 0, Errors: 1.
    vagrant@phpunit:~/code/phpunit$

    I have pushed my code here in case you need to see it... https://github.com/shauntho...