Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Complex Config Test

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 $8.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

There is one important part of the bundle that is not tested yet: our configuration. If the user sets the min_sunshine option, there's no test that this is correctly passed to the service.

And yea, again, you do not need to have a test for everything: use your best judgment. For configuration like this, there are three different ways to test it. First, you can test the Configuration class itself. That's a nice idea if you have some really complex rules. Second, you can test the extension class directly. In this case, you would pass different config arrays to the load() method and assert that the arguments on the service Definition objects are set correctly. It's a really low-level test, but it works.

And third, you can test your configuration with an integration test like we created, where you boot a real application with some config, and check the behavior of the final services.

If you do want to test the configuration class or the extension class, like always, a great way to do this is by looking at the core code. Press Shift+Shift to open FrameworkExtensionTest. If you did some digging, you'd find out that this test parses YAML files full of framework configuration, parses them, then checks to make sure the Definition objects are correct based on that configuration.

Try Shift + Shift again to open ConfigurationTest. There are a bunch of these, but the one from FrameworkBundle is a pretty good example.

Dummy Test Word Provider

We're going to use the third option: boot a real app with some config, and test the final services. Specifically, I want to test that the custom word_provider config works.

Let's think about this: to create a custom word provider, you need the class, like CustomWordProvider, you need to register it as a service - which is automatic in our app - and then you need to pass the service id to the word_provider option. We're going to do all of that, right here at the bottom of this test class. It's a little nuts, and that's exactly why we're talking about it!

Create a new class called StubWordList and make it implement WordProviderInterface. This will be our fake word provider. Go to the Code -> Generate menu, or Command + N on a Mac, and implement the getWordList() method. Just return an array with two words: stub and stub2.

... lines 1 - 2
namespace KnpU\LoremIpsumBundle\Tests;
... lines 4 - 66
class StubWordList implements WordProviderInterface
{
public function getWordList(): array
{
return ['stub', 'stub2'];
}
}

Next, copy the testServiceWiring() method, paste it, and rename it to testServiceWiringWithConfiguration(). Remove the last two asserts: we're going to work more on this in a minute.

... lines 1 - 12
class FunctionalTest extends TestCase
{
... lines 15 - 25
public function testServiceWiringWithConfiguration()
{
$kernel = new KnpULoremIpsumTestingKernel([
'word_provider' => 'stub_word_list'
]);
$kernel->boot();
$container = $kernel->getContainer();
$ipsum = $container->get('knpu_lorem_ipsum.knpu_ipsum');
... line 35
}
}
... lines 38 - 74

Configuring Bundles in the Kernel

Here's the tricky part: we're using the same kernel in two different tests... but we want them to behave differently. In the second test, I need to pass some extra configuration. This will look a bit technical, but just follow me through this.

First, inside the kernel, go back to the Code -> Generate menu, or Command + N on a Mac, and override the constructor. To simplify, instead of passing the environment and debug flag, just hard-code those when we call the parent constructor.

... lines 1 - 38
class KnpULoremIpsumTestingKernel extends Kernel
{
public function __construct()
{
parent::__construct('test', true);
}
... lines 45 - 60
}
... lines 62 - 70

Thanks to that, we can remove those arguments in our two test functions above. But now, add an optional array argument called $knpUIpsumConfig. This will be the configuration we want to pass under the knpu_lorem_ipsum key.

At the top of the kernel, create a new private variable called $knpUIpsumConfig, and then assign that in the constructor to the argument.

... lines 1 - 38
class KnpULoremIpsumTestingKernel extends Kernel
{
private $knpUIpsumConfig;
public function __construct(array $knpUIpsumConfig = [])
{
$this->knpUIpsumConfig = $knpUIpsumConfig;
... lines 46 - 47
}
... lines 49 - 64
}
... lines 66 - 74

Next, find the registerContainerConfiguration() method. In a normal Symfony app, this is the method that's responsible for parsing all the YAML files in the config/packages directory and the services.yaml file.

Instead of parsing YAML files, we can instead put all that logic into PHP with $loader->load() passing it a callback function with a ContainerBuilder argument. Inside of here, we can start registering services and passing bundle extension configuration.

... lines 1 - 56
public function registerContainerConfiguration(LoaderInterface $loader)
{
$loader->load(function(ContainerBuilder $container) {
... lines 60 - 62
});
}
... lines 65 - 74

First, in all cases, let's register our StubWordList as a service: $container->register(), pass it any id - like stub_word_list - and pass the class: StubWordList::class. It doesn't need any arguments.

... lines 1 - 58
$loader->load(function(ContainerBuilder $container) {
$container->register('stub_word_list', StubWordList::class);
... lines 61 - 62
});
... lines 64 - 74

Next, we need to pass any custom knpu_lorem_ipsum bundle extension configuration. Do this with $container->loadFromExtension() with knpu_lorem_ipsum and, for the second argument, the array of config you want: $this->knpUIpsumConfig.

... lines 1 - 58
$loader->load(function(ContainerBuilder $container) {
... lines 60 - 61
$container->loadFromExtension('knpu_lorem_ipsum', $this->knpUIpsumConfig);
});
... lines 64 - 74

Basically, each test case can now pass whatever custom config they want. The first won't pass any, but the second will pass the word_provider key set to the service id: stub_word_list.

... lines 1 - 12
class FunctionalTest extends TestCase
{
... lines 15 - 25
public function testServiceWiringWithConfiguration()
{
$kernel = new KnpULoremIpsumTestingKernel([
'word_provider' => 'stub_word_list'
]);
... lines 31 - 35
}
}
... lines 38 - 74

The downside of an integration test is that we can't assert exactly that the StubWordList was passed into KnpUIpsum. We can only test the behavior of the services. But since that stub word list only uses two different words, we can reasonably test this with $this->assertContains('stub', $ipsum->getWords(2)).

... lines 1 - 25
public function testServiceWiringWithConfiguration()
{
... lines 28 - 34
$this->assertContains('stub', $ipsum->getWords(2));
}
... lines 37 - 74

Ready to try this? Find your terminal and... run those tests!

./vendor/bin/simple-phpunit

Ah man! Our new test fails! Hmm... it looks like it's not using our custom word provider. Weird!

It's probably weirder than you think. Re-run just that test by passing --filter testServiceWiringWithConfiguration:

./vendor/bin/simple-phpunit --filter testServiceWiringWithConfiguration

It still fails. But now, clear the cache directory:

rm -rf tests/cache

And try the test again:

./vendor/bin/simple-phpunit --filter testServiceWiringWithConfiguration

Holy Houdini Batman! It passed! In fact, try all the tests:

./vendor/bin/simple-phpunit

They all pass! Black magic! What the heck just happened?

When you boot a kernel, it creates a tests/cache directory that includes the cached container. The problem is that it's using the same cache directory for both tests. Once the cache directory is populated the first time, all future tests re-use the same container from the first test, instead of building their own.

It's a subtle problem, but has an easy fix: we need to make the Kernel use a different cache directory each time it's instantiated. There are tons of ways to do this, but here's an easy one. Go back to the Code -> Generate menu, or Command + N on a Mac, and override a method called getCacheDir(). Return __DIR__.'/cache/' then spl_object_hash($this). So, we will still use that cache directory, but each time you create a new Kernel, it will use a different subdirectory.

... lines 1 - 38
class KnpULoremIpsumTestingKernel extends Kernel
{
... lines 41 - 65
public function getCacheDir()
{
return __DIR__.'/cache/'.spl_object_hash($this);
}
}
... lines 71 - 79

Clear out the cache directory one last time. Then, run the tests!

./vendor/bin/simple-phpunit

They pass! Run them again:

./vendor/bin/simple-phpunit

You should now see four unique sub-directories inside cache/. I won't do it, but to make things even better, you could clear the cache/ directory between tests with a teardown() method in the test class.

Leave a comment!

16
Login or Register to join the conversation
erop Avatar
erop Avatar erop | posted 3 years ago | edited

Getting stuck in solving the following problem.

I’m building an application which accepts a command from the front-end and “broadcasts” it to all the services available. Simple as that. Services mentioned are supposed to be an access layer for third-party REST APIs.

I would like to implement each service as a separate bundle which in its turn must implement one of my “internal” contracts/interfaces. Hope it sounds reasonable as in this case I could just give my contracts to outsourcing developers not allowing them inside my main application.

I don’t want to restrict each bundle in its own dependencies. I just want them to implement my interface(s). But let’s assume that one of those bundles would like to use symfony/http-client as a dependency to talk to a third-party REST API. Symfony HttpClient Component is rather good (no need to use Guzzle, etc.) and moreover supports scoped_clients. Got the idea? I have a dependency of Symfony HttpClient in my main application and would like that each bundle could configure it for yourself. Thus I would like to set up that scoped HttpClient straight in @MyBundle/Resources/config/services.xml. Does it violate any reasonable logic of the Symfony framework? Is this allowed? If so what I’m doing wrong if I get an error while putting something like this https://gist.github.com/erop/44dc45aee6f7c7c47b9d50bc7e5bf714 . The error message is ``In XmlFileLoader.php line 681:

There is no extension able to load the configuration for "framework:config" (in /app/libs/<bundle_dir>/src/DependencyInjection/../Resources/config/services.xml). Looked for namespace "http://symfony.com/schema/dic/symfony&quot;, found none `` This is the first point I’m getting stuck.

Looks like I miss some concepts of service container. Or I’m doing something wrong to properly set it up. Could someone give a direction for solving the issue?

Reply
erop Avatar

Looks like the answer for my question is here https://symfony.com/doc/cur... . In fact I implemented PrependeExtensionInterface for 'framework' extension and it works OK now!

1 Reply

Hey erop!

To give you a bigger answer about this, I'll mention 2 things :).

1) When you're inside your bundle's "extension" class (which is what you use to your bundles services.xml file), you're passed a ContainerBuilder object. But that is NOT the "main" ContainerBuilder. Symfony passes each bundle an empty ContainerBuilder. Then, you add your services to it and - eventually - Symfony merges all of them together. It does that so that each bundle can't "interfere" with the services for other bundles. THAT is why you got the "There is no extension able...." thing - only the "main" ContainerBuilder is aware of the ability to process the "framework" config. That's also on purpose - the bundle-config (like framework, or twig, or doctrine) is really meant to be used by end-users and not by applications.

2) So, most of the time, when you want to configure another bundle in some way, there is some non-configuration hook point to do it. Maybe you need to create an event listener, or create a service with some special tag to "hook into" the system. What you're doing is, sort of, the "last resort". I'm not saying it's wrong (I've definitely seen bundles do it) - but it's the last place to look for a hook point. In you case, if it works well, I actually don't see any big issue with it :).

Cheers!

1 Reply

Hey erop

Interesting use-case, I'm glad that you could find the solution by yourself. Thanks for sharing it with others!

Cheers!

1 Reply
Default user avatar

As soon as functional testing is involved having different *configurations* is needed, which likely means to rebuild the (whole?) container's cache between each test.

With only a dozen of tests bootstrapping the kernel, completing already takes minutes.
This makes me think of this issue https://github.com/symfony/...

Is there a way to either optimize by not creating a cache at all?
Or a way to selectively rebuild only some parts of the cache? (In my case only the configuration of one bundle changes over tests).

Reply

Hey @Raph!

Are you testing your application or testing a bundle that you will share, like we do in this tutorial? In this tutorial, indeed, you might find that you boot several different kernels with configuration. But these kernels are so small, they shouldn't take long at all to boot (and for the cache to be built). If you're testing your application (where, indeed, the container/kernel can become quite large), then that's different. However, in that case, you typically are booting your kernel just so you can fetch out services. And so, you typically are only booting your *one* kernel - and not rebuilding its cache.

I think your situation is maybe more the second... but with some special situation? You said:

> In my case only the configuration of one bundle changes over tests

Can you tell me more about that? Are you testing your application... but sometimes you boot your one kernel... with different configuration being passed to a specific bundle? I don't understand... so I bet I'm missing something ;).

Cheers!

Reply
Default user avatar
Default user avatar Raph | weaverryan | posted 3 years ago | edited

It's a bundle and it can be configured. (An app' requiring it would do that from config/package/my-bundle.yaml).

I'd like to test multiple bundle's configurations especially since its configuration affects the services it provides. For example if `driver1 is set in the configuration, then it will create a mybunde.Driver1 service. It's not an uncommon pattern. For example the <i>qpush-bundle</i> creates one Queue` service per configuration-defined queue : https://github.com/uecode/qpush-bundle/blob/master/src/DependencyInjection/UecodeQPushExtension.php#L63 (although I'm not sure it deserves being named the "Service Factoring" as in https://symfonycasts.com/screencast/phpspec/factory-class)

In such a case, each test needs to bootstrap the kernel then clear its cache and in my case this takes 4 minutes for less 20 tests (30min for my testsuite on GitLab.com), almost exclusively dedicated to booting the kernel and cache-clearing afterwards. It'd run under 15 seconds with a static kernel (but most tests would then fail).

Reply

Hey Raph!

Yep, it makes perfect sense then - I would probably do something very similar. A few questions/points, however:

1) You shouldn't need to specifically clear the cache after. I mean, as long as you make sure that each kernel has its own "cache" directory (I often do this by setting some random "key" in the kernel's constructor, setting it on a property, and using it in getCacheDir), then nautrally each kernel will start pre-cleared because its cache will be empty. I'm not sure how much this will speed things up... but try it :).

2) Typically in a test like this (at least when you're creating a *truly* re-usable bundle - i.e. one that could be used in any Symfony app), the "kernel" that you're creating is very small - you usually are booting just a few bundles that your bundle depends on - for example - https://github.com/symfony/... - which just needs 3 bundles. In these cases, the kernel boots very fast - certainly in a few seconds... but probably under a second. Are you needing to boot many bundles / your entire app to run the tests?

Cheers!

Reply
Default user avatar

Thinking further about this issue, my testsuite which boots the kernel many dozens of times, goes from ~40 seconds to >15 minutes... when enabling code coverage. Said another way: it's all about xdebug slowing-down kernel boot.
I fear this thread goes off-topic and I don't want to add to much unrelated noise.
(But I hope xdebug can be selectively disabled during phpunit kernel-boot process)

Reply
Default user avatar

Closing that specific thread on a positive note: I switched to pcov (implying an update to phpunit 8) and disabled xdebug.

The testsuite with coverage runs in less than a minute (as fast as if no coverage was requested at all) instead of 15 minutes.

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

Hi,

The test fails ("x y" does not contain "stub").
It's not loading StubWordList, got "KnpU\LoremIpsumBundle\KnpUWordProvider" from the dump.
Any clue what I did it wrong?
I've made some changes on service.xml, but I think it does not affect the tests.

thanks

Reply

Hey Felipe L.

Did you register the StubWordList service on your testing kernel?
Where those the "X Y" comes from?

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

Sorry MolloKhan , ignore all this.
I forgot to remove the service from config.yaml, (lesson 2!)
I've removed and all works, rewrite services.xml as lesson 8 and all good.

thanks for you time anyway.

Reply

Ha! Nice, I'm glad to hear that you could fix your problem
And don't worry about this - everybody makes mistakes ;)

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

Hi MolloKhan ,

I've done some debugging and I'm not sure that my <i>CustomWordProvider</i> is working at all.
I had to change add some lines on <i>service.xml</i> in order to make the bundle works.

Latest version of that file, I got from Lesson 8 looks like this:

`
<service id="knpu_lorem_ipsum.knpu_ipsum" class="KnpU\LoremIpsumBundle\KnpUIpsum" public="true">

 <argument type="service" id="knpu_lorem_ipsum.word_provider" />

</service>
<service id="knpu_lorem_ipsum.knpu_word_provider" class="KnpU\LoremIpsumBundle\KnpUWordProvider" />
<service id="knpu_lorem_ipsum.word_provider" alias="knpu_lorem_ipsum.knpu_word_provider" public="false" />
<service id="KnpU\LoremIpsumBundle\KnpUIpsum" alias="knpu_lorem_ipsum.knpu_ipsum" public="false" />
`

Got the following error:
<a href="https://imgur.com/a/v22OLJQ&quot;&gt;Cannot autowire service</a>

Then, I've made some changes and my service.xml looks like this:

`
<service id="knpu_lorem_ipsum.knpu_ipsum" class="KnpU\LoremIpsumBundle\KnpUIpsum" public="true">

<argument type="service" id="knpu_lorem_ipsum.knpu_word_provider" />

</service>
<service id="knpu_lorem_ipsum.knpu_word_provider" class="KnpU\LoremIpsumBundle\KnpUWordProvider" />
<service id="knpu_lorem_ipsum.word_provider" alias="knpu_lorem_ipsum.knpu_word_provider" public="false" />
<service id="KnpU\LoremIpsumBundle\KnpUWordProvider" alias="knpu_lorem_ipsum.knpu_word_provider" />
<service id="KnpU\LoremIpsumBundle\KnpUIpsum" alias="knpu_lorem_ipsum.knpu_ipsum" public="false" />
<service id="KnpU\LoremIpsumBundle\KnpUWordProvider" />
<service id="KnpU\LoremIpsumBundle\WordProviderInterface" alias="KnpU\LoremIpsumBundle\KnpUWordProvider" />
`

After these changes, it works but I'm not quite sure that CustomWordProvider is being loaded properly.
If I added 'beach', following the lesson, the word does not appear on the page.

thanks
Felipe

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

Hi MolloKhan ,

when you say register, you mean on <i>registerContainerConfiguration</i>, right?

If it does, the method looks like this:
`
$loader->load(function(ContainerBuilder $container) {

        $container->register('stub_word_list', StubWordList::class);

        $container->loadFromExtension('knpu_lorem_ipsum', $this->knpUIpsumConfig);
    });

`

thanks
Felipe

Reply
Cat in space

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

This tutorial is built using Symfony 4, but most of the concepts apply fine to Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "doctrine/annotations": "^1.8", // v1.8.0
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knpuniversity/lorem-ipsum-bundle": "*@dev", // dev-master
        "nexylan/slack-bundle": "^2.0,<2.2", // v2.0.1
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.1.6
        "symfony/asset": "^4.0", // v4.0.6
        "symfony/console": "^4.0", // v4.0.6
        "symfony/flex": "^1.0", // v1.18.7
        "symfony/framework-bundle": "^4.0", // v4.0.6
        "symfony/lts": "^4@dev", // dev-master
        "symfony/twig-bundle": "^4.0", // v4.0.6
        "symfony/web-server-bundle": "^4.0", // v4.0.6
        "symfony/yaml": "^4.0", // v4.0.6
        "weaverryan_test/lorem-ipsum-bundle": "^1.0" // v1.0.0
    },
    "require-dev": {
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.4
        "sensiolabs/security-checker": "^4.1", // v4.1.8
        "symfony/debug-bundle": "^3.3|^4.0", // v4.0.6
        "symfony/dotenv": "^4.0", // v4.0.6
        "symfony/maker-bundle": "^1.0", // v1.1.1
        "symfony/monolog-bundle": "^3.0", // v3.2.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.3.3
        "symfony/stopwatch": "^3.3|^4.0", // v4.0.6
        "symfony/var-dumper": "^3.3|^4.0", // v4.0.6
        "symfony/web-profiler-bundle": "^3.3|^4.0" // v4.0.6
    }
}
userVoice