After trying all the options in this post I settled down with simple solution:
public: true
in all my configs.
The only approach that works out of the box and requires 0-setup.
2 versions of Symfony are affected by this dissonance between services and tests. Do you use Symfony 3.4 or 4.0? Do you want to test your services, but struggle to get them in a clean way?
Today we look at possible solutions.
Since Symfony 3.4 all services are private by default.
That means you can't get service by $this->get(App\SomeService::class)
or $this->container->get(App\SomeService::class)
anymore, but only only via constructor.
That's ok until you need to test such service:
use App\SomeService;
use PHPUnit\Framework\TestCase;
final class SomeServiceTest extends TestCase
{
public function testSomeMethod()
{
$kernel = new AppKernel;
$kernel->boot();
$container = $kernel->getContainer();
// this line is important ↓
$someService = $container->get(SomeService::class);
// ...
}
}
When we run the test:
vendor/bin/phpunit tests
This exception will stop us:
The "App\SomeService" service or alias has been removed or inlined when the container
was compiled. You should either make it public, or stop using the container directly
and use dependency injection instead.
...make it public...
Ok!
# app/config/config.yml
services:
_defaults:
autowire: true
App\:
resource: ..
+
+ App\SomeService:
+ public: true
And run tests again:
vendor/bin/phpunit tests
✅ Voilá!
As you can see, we can load dozens of service from App\
by 2 lines. But to test 1, we need to add 2 extra lines to config.
# app/config/config.yml
services:
_defaults:
autowire: true
App\:
resource: ..
+
+ # for tests only
+ App\SomeService:
+ public: true
+
+ App\AnotherService:
+ public: true
+
+ App\YetAnotherService:
+ public: true
This is one to many code smell.
Also, we can extract it to test config tests/config/config.yml
, so it's easier to hide the smell.
Or just make everything public, like I did in Symplify 6 months ago:
services:
_defaults:
autowire: true
+ # for tests only
+ public: true
App\:
resource: ..
But Symfony folks will not be happy to see this, because they need people to use private services. Why? So they learn to use constructor injection in services instead of $this->get(...)
. So how should we do it the Symfony-way?
We're not alone asking this question. There are over 52 results for "symfony tests private services" on StackOverflow at the time being:
But what saint options we have?
This is now fixed in Symfony 4.1 with Simpler service testing.
Do you use
Symfony\Bundle\FrameworkBundle\Test\KernelTestCase
orSymfony\Bundle\FrameworkBundle\Test\WebTestCase
for your tests? Just upgrade to Symfony 4.1 and you're done.
But if you create open-source, you usually stick with last LTS, Symfony 3.4. How to solve it there?
It's reasonable we want to keep all configs untouched, no matter if we're in dev or tests.
# app/config/config.yml
services:
_defaults:
autowire: true
App\:
resource: ..
And tests as well:
use App\SomeService;
use PHPUnit\Framework\TestCase;
final class SomeServiceTest extends TestCase
{
public function testSomeMethod()
{
$kernel = new AppKernel;
$kernel->boot();
$container = $kernel->getContainer();
$someService = $container->get(SomeService::class);
// ...
}
}
If there would only be one place with a switch, that would make that all code smells go away and let us test. That would be awesome, right? How can we achieve that? Any ideas?
Compiler pass allows us to write nice, decoupled and reusable code. After all, the solution for Symfony 4.1 is done by a compiler pass, that creates public 'test.service-name' aliases.
Let's create one for our PHPUnit test cases:
<?php
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class PublicForTestsCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $containerBuilder): void
{
if (! $this->isPHPUnit()) {
return;
}
foreach ($containerBuilder->getDefinitions() as $definition) {
$definition->setPublic(true);
}
foreach ($containerBuilder->getAliases() as $definition) {
$definition->setPublic(true);
}
}
private function isPHPUnit(): bool
{
// there constants are defined by PHPUnit
return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__');
}
}
And register it in our Kernel:
<?php
final class AppKernel extends Kernel
{
protected function build(ContainerBuilder $containerBuilder): void
{
$containerBuilder->addCompilerPass(new PublicForTestsCompilerPass());
}
}
This removes all public: true
lines from all your configs.
✅ That's it!
public: true
from our configs."It is. But in 6 months of using this method I got different feedback from the PHP community:
public: true
for Symfony\Console\Application in bin file, but not in tests? ❌People were confused 😕🤔. The trade of compiler pass feature was putting too much knowledge pressure on the programmers. The application uses constructor injection everywhere, so there is no real added value by working with term public/private services.
In the end I removed the compiler pass and moved back to public: true
in all configs right bellow autowire: true
:
services:
_defaults:
autowire: true
+ public: true
Thanks to that, the whole process became clear:
autowire: true
→ all configs have the same setup ✅Happy coding!
Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!