Buy

Integration Tests

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.

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

90 lines src/AppBundle/Entity/Enclosure.php
... 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.

18 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... lines 1 - 7
class EnclosureBuilderServiceIntegrationTest extends KernelTestCase
{
... lines 10 - 16
}

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 testItBuildsEnclosureWithTheDefaultSpecification(). Hmm, that's a big name!

18 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... lines 1 - 7
class EnclosureBuilderServiceIntegrationTest extends KernelTestCase
{
public function testItBuildsEnclosureWithDefaultSpecifications()
{
... lines 12 - 15
}
}

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.

18 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... 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 testItBuildsEnclosureWithTheDefaultSpecification

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.

23 lines app/config/config_test.yml
... 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.

23 lines app/config/config_test.yml
... 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.

18 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... 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 testItBuildsEnclosureWithTheDefaultSpecification

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.

44 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... 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.

44 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... 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!

44 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... 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.

44 lines tests/AppBundle/Service/EnclosureBuilderServiceIntegrationTest.php
... 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 testItBuildsEnclosureWithTheDefaultSpecification

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

./vendor/bin/phpunit --filter testItBuildsEnclosureWithTheDefaultSpecification

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!