Isolation of tests in Symfony2

For achieving good tests in your project, you will need to have a correct isolation of them. When a test executes, it should leave the application in the same state as when he entered it.

In Symfony2, the most common-case is the database isolation : when we are testing, we expect from our application to isolate the test with a transaction and to rollback it at the end.

Functional tests in Symfony2

In Symfony2, functional testing is achieved with the test client. The test client is responsible of requesting the kernel, and fetching a response from a request.

The test client allows to easily request the kernel and crawl the response.

For example, we can achieve :

<?php
class MyTest extends PHPUnit_Framework_TestCase
{
    public function testSomething()
    {
        $client = $this->createClient();
        $crawler = $client->request('GET', '/test');

        $this->assertEquals(200, $client->getResponse()->getStatusCode());
        $this->assertEquals('Hello', $crawler->filter('h1')->text());
    }
}

The test client handles requests to the kernel and returns crawler, for easing navigation through the returned response.

In Symfony2, the test client is a service of the container. Its service name is test.client.

It is possible to override your test client in your container, to add some custom methods and behaviors, as we will see in the following chapters.

Basic overriding of test client

To simply override the Test client, redefine in your configuration :

<parameters>
    <parameter name="test.client.class">Alom\Website\MainBundle\Test\Client</parameter>
</parameters>

Then, just redefine the test client class, like this :

<?php
namespace Alom\Website\MainBundle\Test;
use Symfony\Bundle\FrameworkBundle\Client as BaseClient;

class Client extends BaseClient
{
}

This way, we can override the test client with our custom methods. For example, we can create a connect method to automatically connect the user to the website.

<?php
// ...
    public function connect($username, $password)
    {
        $crawler = $this->request('GET', '/login');
        // ....
    }

With this method, you can create anything that you need for easing tests of your application.

Isolation of Doctrine

Each time you make a request, Symfony2 creates a new container for it. This means that the Doctrine objects (for DBAL and ORM) are different for each request.

And here is the problem : we have a different connection for each request, so it's not possible to have the same connection for every request of the client.

The solution is to pass the service through different requests, and isolate them in a transaction.

To achieve this, we override the doRequest method of the test client, which is responsible of passing requests to the kernel.

<?php
// ...
    static protected $connection;
    protected $requested;

    protected function doRequest($request)
    {
        if ($this->requested) {
            $this->kernel->shutdown();
            $this->kernel->boot();
        }

        $this->injectConnection();
        $this->requested = true;

        return $this->kernel->handle($request);
    }

    protected function injectConnection()
    {
        if (null === self::$connection) {
            self::$connection = $this->getContainer()->get('doctrine.dbal.default_connection');
        } else {
            if (! $this->requested) {
                self::$connection->rollback();
            }
            $this->getContainer()->set('doctrine.dbal.default_connection', self::$connection);
        }

        if (! $this->requested) {
            self::$connection->beginTransaction();
        }
    }

Isn't it integrated in Symfony2 ?

The answer is no. This code, here is quite simple, 20 lines for achieving what we want, in our project. Integrating it in Symfony2 would mean 100-200 lines for :

  • Disabling it when Doctrine is not bundled
  • Add a support of multiple connections
  • Override the test client when the Doctrine bundle is activated
  • Add some options for edge-cases : no isolation needed, ODM, etc.

There is also the question of isolation for other services of your application : SolR engine, Web Services, and so on.

The better place for it (in my humble opinion) is in your project. Because even if it's a common case, the implementation is not trivial.

cordoval November 10, 2011

so is the self::$connection->rollback(); operation undoing the read and writes that were done? that is amazing, this is very powerful for as you say isolating the database since the database will be untouched right? that is the idea or which is the idea if not?

Very handy thing to get at one's fingertips. I wonder why this is not PR'ed into sf2 repos.

Benjamin Eberlei November 18, 2011

Can you make a cookbook entry out of it please? :-)