Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

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!

31
Login or Register to join the conversation
Denis N. Avatar
Denis N. Avatar Denis N. | posted 4 years ago

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

2 Reply
Zacarías C. Avatar
Zacarías C. Avatar Zacarías C. | Denis N. | posted 4 years ago

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.

3 Reply

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/sebastianbergmann/phpunit/blob/f462942d1cc58cc02e2c1a247d648c215c208354/src/Framework/TestCase.php#L417-L422

Cheers!

2 Reply
Zacarías C. Avatar
Zacarías C. Avatar Zacarías C. | Denis N. | posted 4 years ago

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

Reply
davidmintz Avatar
davidmintz Avatar davidmintz | posted 1 year ago | edited

A dumb, basic question: how do I configure my test environment to use a mysql database that is running inside a docker container? I created a second database for testing inside the same container as my dev db, but I'm baffled as to how to get my tests to communicate with it.

That is, I have a <i>dev</i> database service called ´database' set up in docker-compose.yaml, and when I need CLI access to it, I just say docker-compose exec database mysql -umy_username --password=my_password my_dev_db_name and don´t need to worry about the port number. So I don't know how to craft a DATABASE_URL to put in my .env.test.local.

Reply
davidmintz Avatar

OK after about 4 hours of hell I think I might have an answer, maybe you can tell me if I am more or less right. You don´t explicitly set DATABASE_URL yourself.

1) inside the docker container, create a database with the same name as your dev database plus the suffix _test (e.g., my_dev_db_test)
2) inside the docker container, give your dev user full privileges on my_dev_db_test
3) run symfony console doctrine:database:create --env=test
4) run symfony console doctrine:fixtures:load --env=test
5) the coup de graçe that gave me the most trouble: run tests with the command symfony php vendor/bin/phpunit

Reply

Hey David,

Yes, all your listed sounds correct! Actually, with the full privileges DB user you can create DB with symfony command you mentioned in step 3. So, the 1st step is redundant then. And yes, when we're talking about Docker - try to use symfony CLI everywhere where possible, as it takes care of setting up env vars correctly for you.

Cheers!

Reply
davidmintz Avatar
davidmintz Avatar davidmintz | Victor | posted 1 year ago

Victor! Sorry to go off-topic but it's good to hear from you, and I fervently hope you and all your people (loved ones, and all the rest) are doing OK under the circumstances, which by all reports are horrendous.

Reply

Hey David,

Yes, this is a really hard and horrendous time, probably not only to us but also and to the whole world because the conflict might expand quickly. I'm OK, people are heroically standing, government still does its best I think. Thank you for mentioning this!

Cheers!

Reply
Default user avatar
Default user avatar Eugene V. Kaurov | posted 1 year ago

How to separate integration tests from unit-tests?
You located your integration test in the folder '/tests/' which is standard for unit-test.
So every run of PHPUnit will run all of them.
Usually integration test are located in different folder or somehow marked so fast unit-tests can be executed separately from time-consuming integration tests.
What is a best practice in Symfony?

Reply

Hey Eugene V. Kaurov!

I don't think there is one best practice for how to organize these test directory. Personally I organize integration and unit tests in the same way without any specific separation. If you want to be able to execute the unit tests independently from the integration tests, you could use PHPUnit groups above the classes or methods - e.g. @group integration. Of course, it would also be totally fine to create a structure like tests/integration/ if you like that better :).

Cheers!

Reply
Bruno D. Avatar
Bruno D. Avatar Bruno D. | posted 2 years ago

The service's public alias is such a good and ingenious idea!
Thank you, I was trying to mock the IO during an integration testing, your solution made my day!

Reply

Hey, Bruno! I'm glad you liked the idea! :)

Reply
Benoit L. Avatar
Benoit L. Avatar Benoit L. | posted 3 years ago

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.

Reply

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!

Reply
Benoit L. Avatar

nice to hear that solution, thanks !

Reply

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!

Reply
Benoit L. Avatar

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

Reply

Hey Yvon,

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

Cheers!

Reply

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`.

Reply

Hey AmalricBzh

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

Thanks for reporting it. Thanks!

Reply
avknor Avatar

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?

Reply

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!

-1 Reply
Ahaaje Avatar

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

Reply

Hey Arne K. !

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/screencast/phpunit/full-mock-example#codeblock-2505d8fa36 (and if it's helpful, you can see the diff here: https://github.com/knpuniversity/phpunit/commit/ceca8a69b81cfa240cf0b6cd9d3ea92dc2b2fd65).

After we add the for loop, the final product looks like this: https://symfonycasts.com/screencast/phpunit/full-mock-example#codeblock-aabfeeaf56

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!

1 Reply
Felipe-L Avatar
Felipe-L Avatar Felipe-L | posted 4 years ago

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

Reply

Hey Felipe L.

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!

Reply
Felipe-L Avatar
Felipe-L Avatar Felipe-L | MolloKhan | posted 4 years ago | edited

Hi MolloKhan , 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

Reply

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!

Reply
Shaun T. Avatar
Shaun T. Avatar Shaun T. | posted 5 years ago

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...

Reply

Yo Shaun T.!

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!

Reply
Cat in space

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

While the fundamentals of PHPUnit haven't changed, this tutorial *is* built on an older version of Symfony and PHPUnit.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.0, <7.4",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/doctrine-bundle": "^1.6", // 1.10.3
        "doctrine/orm": "^2.5", // v2.7.2
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.2
        "sensio/distribution-bundle": "^5.0.19", // v5.0.21
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.28
        "symfony/monolog-bundle": "^3.1.0", // v3.1.2
        "symfony/polyfill-apcu": "^1.0", // v1.6.0
        "symfony/swiftmailer-bundle": "^2.3.10", // v2.6.7
        "symfony/symfony": "3.3.*", // v3.3.13
        "twig/twig": "^1.0||^2.0" // v2.4.4
    },
    "require-dev": {
        "doctrine/data-fixtures": "^1.3", // 1.3.3
        "doctrine/doctrine-fixtures-bundle": "^2.3", // v2.4.1
        "liip/functional-test-bundle": "^1.8", // 1.8.0
        "phpunit/phpunit": "^6.3", // 6.5.2
        "sensio/generator-bundle": "^3.0", // v3.1.6
        "symfony/phpunit-bridge": "^3.0" // v3.4.30
    }
}
userVoice