My Symfony functional testing toolbox

Alessandro Lai / @AlessandroLai

PUG Milano - 20 gennaio 2020, online

Who am I?

Alessandro @ SFDay 2015 SFDay 2015 - Reggio Emilia

Slow test suites

If a build / test suite takes more than 10 minutes to run, it's worthless.

Paraunit performace slide @ SFDay 2015

The testing pyramid

Functional tests
Unit tests

...and what was going wrong

Functional tests
Unit tests

My pyramid foundation

Functional tests
Unit tests
Static code analysis

Static code analysis

The PHPStan website The Psalm website

Speed up your tests

Parallelization & isolation

When you ran multiple tests on the same DB at the same time, you run intro troubles...

Kernel caching

facile-it/paraunit-testcase was built on top of something else...

Good fixtures hygiene

DB vendor is part of your system

  • It's not worth it to change DB vendor only for tests

Validation of entities

  • I used strong validation before persistence
    in multiple projects
  • Strict validation ensures that data is good
    once it reaches the DB

Good fixtures lead to good tests

  • Fixtures should resemble real data
    AKA don't put invalid data in your entities
  • Build *FixtureBuilder utility classes to create fixtures in known valid states
  • Randomized fixtures are good
    (use Faker or something else, since it got retired...)

Fixture belong outside of src/

Using the tools

No reload, no cleanup required

public function testDeleteFoo(): void
    // ...
    $client->request('DELETE', '/api/foo/123');

    // no cleanup or fixture reload required!

public function testPutFoo(): void
    // ...
    $client->request('PUT', '/api/foo/123', $payload);
    // ...

Getting services from the container

 * @template T of object
 * @param class-string<T> $serviceId
 * @return T
protected function getService(string $serviceId): object
    $service = $this->getContainer()->get($serviceId);
    $this->assertInstanceOf($serviceId, $service);

    return $service;
PHPStan and Psalm Support Coming to PhpStorm Soon - JetBrains' Blog

Testing a service directly

public function testFindLatestReturnsOnlyOneResult(): void
    $repository = $this->getService(FooRepository::class);

    $result = $repository->findLatest();

    $this->assertCount(1, $result);
    $this->assertContainsOnlyInstancesOf(Foo::class, $result);

Testing entity validation (1/2)

protected function assertIsValid(object $entity): void
    $validator = $this->getContainer()->get('validator');
    $result = $validator->validate($entity);

    $this->assertCount(0, $result, 'Validation failed: ' . $result);
protected function assertValidationFailsAtPath(string $path, object $entity): void
    $violationList = $this->getContainer()->get('validator')->validate($entity);
    foreach ($violationList as $violation) {
        if ($violation->getPropertyPath() === $path) {
    $this->fail('No violation found at path ' . $path);

Testing entity validation (2/2)

public function testNewFooIsValid(): void
    $this->assertIsValid(new Foo('baz'));
/** @dataProvider invalidDataProvider */
public function testFooIsInvalid(Foo $foo, string $errorPath): void
    $this->assertValidationFailsAtPath($errorPath, $foo);
public function testFooIsInvalidWithWrongBar(): void
    $foo = new Foo();


    $this->assertValidationFailsAtPath('bar', $foo);


Doctrine Test Bundle limitations

  • Commands with implicit commits break it (TRUNCATE or any schema change)
  • No logs nor after-test DB inspections
    (but invalid queries still crash)
  • Last resort: force transaction commit
  • Tests can fail due to concurrency & DB locks
    (but Paraunit retries those)

Data providers are a strange place...

  • PHPUnit's data providers are executed ahead of time
  • Do not interact with the entity manager there

All containers are not the same...

public function test(): void
    $client = self::createClient();
        "Those are not the services you're looking for..."
} you may want the right one

protected function getService(
    string $serviceId,
    KernelBrowser $client = null
): object 
    return $this->getContainer($client)->get($serviceId);

protected function getContainer(KernelBrowser $client = null): ContainerInterface
    if ($client) {
        $clientContainer = $client->getContainer();

        return $clientContainer->get('test.service_container');

    return parent::getContainer();

Thanks! Questions?