Symfony — Functional Tests for Standalone Bundles

Filip Horvat
8 min readFeb 23, 2024

--

In this article, I will provide you with techniques on how to test your standalone bundle using functional and unit tests and much more.

I discovered a shortage of examples in this area, which is the main motivation behind writing this article.

I will also include a GitHub package with the provided example at the end of this article.

I believe this article will be my best on Medium so far, so you might miss out if you stop reading now :)

Bundle example

We will create a standalone bundle that listens to the Doctrine postPersist event and then dispatches a new custom event with the ID and className of persistend entitstored in that event. In the test, we will assert that the event is dispatched.

Here is documentation outlining the best practices for creating standalone bundles:

My example will adhere to the latest best practices, unlike Symfony core bundles. For instance, in Symfony’s security bundle, classes like ‘EventListener’ are placed in the root of the project, whereas, in the newest convention, they should be in the ‘src’ directory.

We will name this bundle ‘persistence-bundle’ and use ‘Fico7489’ as the vendor name for the bundle. You should replace it with your own name for your bundle.

As a result, the composer name for our bundle will be ‘fico7489/persistence-bundle’, and the namespace inside the bundle will be ‘Fico7489\PersistenceBundle’.

Composer

Create a folder named ‘persistence-bundle’ and then run ‘composer init’. You can press Enter to accept the default values for all the prompted questions by Composer.

Now, you should see ‘vendor’ and ‘src’ folders, along with a ‘composer.json’ file containing something similar:

{
"name": "fico7489/persistence-bundle",
"autoload": {
"psr-4": {
"fico7489\\persistence-bundle\\": "src/"
}
},
"authors": [
{
"name": "Filip Horvat",
"email": "fico7489@gmail.com"
}
],
"require": {}
}

Now, we are going to add all the required changes in ‘composer.json.’

We will use the DoctrineListener from the ‘doctrine/orm’ package, and we also want to integrate Doctrine into Symfony by using ‘doctrine/doctrine-bundle’. Additionally, we will include ‘symfony/yaml’ since we plan to load bundle configuration with YAML.

For local development and testing, we will use ‘friendsofphp/php-cs-fixer’ and ‘phpunit/phpunit’ composer packages.

Additionally, we need to add autoload configuration for tests.

Here is our final ‘composer.json’:

{
"name": "fico7489/persistence-bundle",
"description": "Provides an events for persistence",
"type": "symfony-bundle",
"license": "MIT",
"authors": [
{
"name": "Filip Horvat",
"email": "fico7489@gmail.com"
}
],
"require": {
"doctrine/doctrine-bundle": "2.*",
"doctrine/orm": "3.*",
"symfony/yaml": "7.*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpunit/phpunit": "10.*"
},
"autoload": {
"psr-4": {
"Fico7489\\PersistenceBundle\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Fico7489\\PersistenceBundle\\Tests\\": "tests/"
}
}
}

Code

Here is a simple custom event which will be dispatched in DoctrineListener:

<?php

namespace Fico7489\PersistenceBundle\Event;

use Symfony\Contracts\EventDispatcher\Event;

class UpdatedEntity extends Event
{
public function __construct(
private readonly int $id,
private readonly string $className,
) {
}

public function getId(): int
{
return $this->id;
}

public function getClassName(): string
{
return $this->className;
}
}

Here is a DoctrineListener which is dispatching event when some entity is persisted:

<?php

namespace Fico7489\PersistenceBundle\EventListener;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Events;
use Fico7489\PersistenceBundle\Event\UpdatedEntity;
use Psr\EventDispatcher\EventDispatcherInterface;

#[AsDoctrineListener(event: Events::postPersist)]
class DoctrineListener
{
public function __construct(private readonly EventDispatcherInterface $eventDispatcher)
{
}

public function postPersist(PostPersistEventArgs $args): void
{
$entity = $args->getObject();

$this->eventDispatcher->dispatch(new UpdatedEntity($entity->getId(), $entity::class));
}
}

I will add a service to the bundle that will be used for unit testing:

<?php

namespace Fico7489\PersistenceBundle\Service;

class PersistenceBundleService
{
public function test()
{
return 'PersistenceBundleTest';
}
}

The two last things we need are the ‘Bundle Registration Class’ and the ‘Dependency Injection Extension’.

A ‘Bundle registration class’ is a class used to register a bundle in the project’s bundle.php file. It defines details about the bundle, such as the bundle name, paths, etc. According to Symfony documentation:

// config/bundles.php
return [
// ...
Acme\BlogBundle\AcmeBlogBundle::class => ['all' => true],
];

A ‘Dependency Injection Extension’ is a class designed for working with the container, handling tasks such as injecting configuration, and more.

The ‘Bundle registration class’ will be straightforward, with no specific options used in this example:

<?php

namespace Fico7489\PersistenceBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class PersistenceBundle extends Bundle
{
}

The ‘Dependency Injection Extension’ will load bundle configuration from ‘config/services.yaml’, utilizing the YAML file loader:

<?php

namespace Fico7489\PersistenceBundle\DependencyInjection;

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;

class PersistenceExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__.'/../../config')
);

$loader->load('services.yaml');
}
}

The Dependency Injection Extension is located inside the ‘src/DependencyInjection’ folder and is automatically called when you adhere to the prescribed naming and placement rules. You can find more information here:

Configuration

This is configuration inside “config/services.yaml”:

services:
Fico7489\PersistenceBundle\EventListener\DoctrineListener:
autowire: true
autoconfigure: true

We configure Symfony to autoconfigure the DoctrineListener, which means it will be registered as a listener due to the #[AsDoctrineListener(event: Events::postPersist)] annotation within it. Without autoconfigure, the class wouldn't be registered as a listener because only classes and files defined as autoconfigure are processed and registered for events, commands, etc.

Additionally, we instruct Symfony to autowire the class, allowing Symfony to inject the real implementation of the EventDispatcherInterface into the constructor where it is typehinted.

Prepare everything for testing

Within the ‘tests’ folder, we will add three subfolders:

  • Functional” for functional testing
  • Unit” for unit testing
  • Util” for util classes which are used for tests

Inside “Util” folder there will be no any tests just classes which will help us to achieve functional testing. That folder is not from any recommendation or something else, that is something that I made up my own, but there are no any recommendation how to do that in other maybe correct way.

Inside “Util” folder we will have 2 folders:

  • App — for Symfony Kernel class and app configuration used in tests
  • Entitiy — for Entities used in tests

Here is a Symfony Kernel class:

<?php

namespace Fico7489\PersistenceBundle\Tests\Util\App;

use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Fico7489\PersistenceBundle\PersistenceBundle;

class Kernel extends BaseKernel
{
public function registerBundles(): array
{
return [
new FrameworkBundle(),
new DoctrineBundle(),

new PersistenceBundle(),
];
}

public function registerContainerConfiguration(LoaderInterface $loader): void
{
$loader->load(__DIR__.'/config/config.yml');
}

public function getCacheDir(): string
{
return 'var/cache';
}

public function getLogDir(): string
{
return 'var/logs';
}

public function getProjectDir(): string
{
return __DIR__.'/../';
}
}

You can see that we have loaded three bundles: ‘FrameworkBundle’ and ‘DoctrineBundle’ as Symfony bundles, and our current bundle, ‘PersistenceBundle’, which we are currently testing.

We also defined path where cache and logs for Kernel are located. They are located inside root folder of the bundle.

Those folders will be created and used only for testing:

  • var/cache
  • var/logs

ProjectDir is set to location of the “Util” folder.

Inside “Util” folder we will also have “Entity” folder where we will create one Doctrine entity which will be loaded only while testing so that we can use that entity in our tests:

<?php

namespace Fico7489\PersistenceBundle\Tests\Util\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
public ?int $id = null;

#[ORM\Column(type: 'text', nullable: true)]
private ?string $name = '';

public function getId(): ?int
{
return $this->id;
}

public function getName(): ?string
{
return $this->name;
}

public function setName(?string $name): void
{
$this->name = $name;
}
}

Inside the ‘App’ folder, we will add a ‘config’ folder containing the configuration for Symfony:

framework:
secret: "test"
test: ~

doctrine:
dbal:
driver: "pdo_sqlite"
path: "%kernel.cache_dir%/../database.db3"

orm:
mappings:
Test:
dir: '%kernel.project_dir%/'
prefix: 'Fico7489\PersistenceBundle\Tests\Util\Entity'

We defined some simple basic symfony framework configuration.

We configured doctrine dbal to use sqlite which will be located at “var/database.db3

We configured doctrine orm to load entities from: “Fico7489\PersistenceBundle\Tests\Util\Entity” folder.

Remember everything from above will be loaded only while testing, it will not be included when you will use that bundle in your project.

The last thing is phpunit configuration:

<?xml version="1.0" encoding="UTF-8"?>

<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<php>
<server name="KERNEL_CLASS" value="Fico7489\PersistenceBundle\Tests\Util\App\Kernel" />
</php>

<testsuites>
<testsuite name="test">
<directory>./tests/</directory>
</testsuite>
</testsuites>
</phpunit>

We defined where kernel class is with KERNEL_CLASS.

Tests

We finally have prepared all for running actual tests.

Unit test

Inside “Unit” folder we will create one simple functional test for our custom service from above:

<?php

namespace Fico7489\PersistenceBundle\Tests\Unit;

use Fico7489\PersistenceBundle\Service\PersistenceBundleService;
use PHPUnit\Framework\TestCase;

class UnitTest extends TestCase
{
public function testSomething()
{
$this->assertEquals('PersistenceBundleTest', (new PersistenceBundleService())->test());
}
}

Functional test

Finally we are here :)

I will just show a complete test, comments inside are telling you what is going out in each step:

<?php

namespace Fico7489\PersistenceBundle\Tests\Functional;

use Fico7489\PersistenceBundle\Event\UpdatedEntity;
use Fico7489\PersistenceBundle\Tests\Util\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Registry;
use Doctrine\ORM\Tools\SchemaTool;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Filesystem\Filesystem;

class FunctionalTest extends KernelTestCase
{
private Container $container;
private static array $events;

public function testSomething(): void
{
//1 and 2 are from symfony docs: https://symfony.com/doc/current/testing.html

// (1) boot the Symfony kernel
self::bootKernel();

// (2) use static::getContainer() to access the service container
$this->container = static::getContainer();

//loading doctrine registry from service container
/** @var Registry $doctrine */
$doctrine = $this->container->get('doctrine');

//updating a schema in sqlite database
$entityManager = $doctrine->getManager();
$metaData = $entityManager->getMetadataFactory()->getAllMetadata();
$schemaTool = new SchemaTool($entityManager);
$schemaTool->updateSchema($metaData);

//start listening to our custom event
$this->eventsStartListen(UpdatedEntity::class);

//create, persist and flush entity
$user = new User();
$user->setName('test');
$entityManager->persist($user);
$entityManager->flush();

//get events which are received
$events = $this->eventsGet(UpdatedEntity::class);

//assert that we received what we want
$this->assertEquals(1, count($events));
$this->assertEquals(1, $events[0]->getId());
$this->assertEquals(User::class, $events[0]->getClassName());

//clear database for the next run
$filesystem = new Filesystem();
$filesystem->remove('var/database.db3');
}

public function eventsStartListen(string $eventClass): void
{
//custom implementation for events listening

/** @var EventDispatcher $eventDispatcher */
$eventDispatcher = $this->container->get(EventDispatcherInterface::class);
$eventDispatcher->addListener($eventClass, function ($event) use ($eventClass): void {
self::$events[$eventClass][] = $event;
});
}

public function eventsGet(string $eventClass): array
{
return self::$events[$eventClass] ?? [];
}
}

Just a note: this is just an example, the next step would be to put functions for listening events, updating schema, booting kernel and everything else to helper methods and to have a clean test like this:

<?php

namespace Fico7489\PersistenceBundle\Tests\Functional;

use Fico7489\PersistenceBundle\Event\UpdatedEntity;
use Fico7489\PersistenceBundle\Tests\Util\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class FunctionalTest extends KernelTestCase
{
public function testSomething(): void
{
// start listening to our custom event
$this->eventsStartListen(UpdatedEntity::class);

// create, persist and flush entity
$user = new User();
$user->setName('test');
$this->entityManager->persist($user);
$this->entityManager->flush();

// get events which are received
$events = $this->eventsGet(UpdatedEntity::class);

// assert that we received what we want
$this->assertEquals(1, count($events));
$this->assertEquals(1, $events[0]->getId());
$this->assertEquals(User::class, $events[0]->getClassName());
}
}

But that is something that is outside of scope of this article.

Final thoughts

I hope that my example will be a good starting point for your bundles and that we will have more useful bundles in Symfony.

You can investigate other Symfony bundles where something similar as in my example was used:

Here is my composer package with the bundle:

https://github.com/fico7489/persistence-bundle

Here is my composer package where bundle is used:

https://github.com/fico7489/persistence-bundle-usage

Inside composer packages you will find instructions inside docs/index.md file.

Please let me know if there is something that is unclear or something that I did not coded by recommendations.

That’s all I hope you enjoyed!

--

--

Filip Horvat

Senior Software Engineer, Backend PHP Developer, Located at Croatia, Currently working at myzone.com