Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Bootstrapping a Killer Test System

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

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

Login Subscribe

Our API is getting more and more complex. And doing manually testing is not a great long-term plan. So let's install some tools to get a killer test setup.

Installing the test-pack

Step one: at your terminal run:

composer require test

This is a flex alias for a package called symfony/test-pack. Remember: packs are shortcut packages that actually install a bunch of other packages. For example, when this finishes... and we check out composer.json, you can see down in require-dev that this added PHPUnit itself as well as a few other tools from Symfony to help testing:

102 lines composer.json
... lines 2 - 87
"require-dev": {
... line 89
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.2.*",
"symfony/css-selector": "6.2.*",
... lines 93 - 95
"symfony/phpunit-bridge": "^6.2",
... lines 97 - 99

It also executed a recipe which added a number of files. We have phpunit.xml.dist, a tests/ directory, .env.test for test-specific environment variables and even a little bin/phpunit executable shortcut that we'll use to run our tests.

Hello browser Library

No surprise, Symfony has tools for testing and these can be used to test an API. Heck, API Platform even has their own tools built on top of those to make testing an API even easier. And yet, I'm going to be stubborn and use a totally different tool that I've fallen in love with.

It's called Browser, and it's also built on top of Symfony's testing tools: almost like a nicer interface above that strong base. It's just... super fun to use. Browser gives us a fluid interface that can be used for testing web apps, like you see here, or testing APIs. It can also can be used to test pages that use JavaScript.

Let's get this guy installed. Copy the composer require line, spin back over and run that:

composer require zenstruck/browser --dev

While that's doing its thing, it's optional, but there's an "extension" that you can add to phpunit.xml.dist. Add it down here on the bottom:

47 lines phpunit.xml.dist
... lines 1 - 3
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
... lines 11 - 35
<extension class="Zenstruck\Browser\Test\BrowserExtension" />
... lines 39 - 45

In the future, if you're using PHPUnit 10, this will likely be replaced by some listener config.

This adds a few extra features to browser. Like, when a test fails, it will automatically save the last response to a file. We'll see this soon. And if you're using JavaScript testing, it'll take screenshots of failures!

Creating our First Test

Ok, we're ready for our first test. In the tests/ directory, it doesn't matter how you organize things, but I'm going to create a Functional/ directory because we're going to be making functional tests to our API. Yup, we'll literally create an API client, make GET or POST requests and then assert that we get back the correct output.

Create a new class called DragonTreasureResourceTest. A normal test extends TestCase from PHPUnit. But make this extend KernelTestCase: a class from Symfony that extends TestCase... but gives us access to Symfony's engine:

... lines 1 - 2
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class DragonTreasureResourceTest extends KernelTestCase

Let's start by testing the GET collection endpoint to make sure we get back the data we expect. To activate the browser library, at the top, add a trait with use HasBrowser:

... lines 1 - 5
use Zenstruck\Browser\Test\HasBrowser;
class DragonTreasureResourceTest extends KernelTestCase
use HasBrowser;
... lines 11 - 18

Next, add a new test method: public function, how about testGetCollectionOfTreasures()... which will return void:

... lines 1 - 7
class DragonTreasureResourceTest extends KernelTestCase
... lines 10 - 11
public function testGetCollectionOfTreasures(): void
... lines 14 - 17

Using browser is dead simple thanks to that trait: $this->browser(). Now we can make GET, POST, PATCH or whatever request we want. Make a GET request to /api/treasures and then, just to see what that looks like, use this nifty ->dump() function:

... lines 1 - 7
class DragonTreasureResourceTest extends KernelTestCase
... lines 10 - 11
public function testGetCollectionOfTreasures(): void

Running our Tests through the symfony Binary

How cool is that? Let's see what it looks like. To execute our test, we could run:

php ./vendor/bin/phpunit

That works just fine. But one of the recipes also added a shortcut file:

php bin/phpunit

When we run that, ooh, let's see. The dump() did happen: it dumped out the response... which was some sort of error. It says:

SQLSTATE: connection to server port 5432 failed.

Hmm, it can't connect to our database. Our database is running via a Docker container... and then, because we're using the symfony web server, when we use the site via a browser, the symfony web server detects the Docker container and sets the DATABASE_URL environment variable for us. That's how our API has been able to talk to the Docker database.

When we've run commands that need to talk to the database, we've been running them like symfony console make:migration... because when we execute things through symfony, it adds the DATABASE_URL environment variable... and then runs the command.

So, when we simply run php bin/phpunit... the real DATABASE_URL is missing. To fix that, run:

symfony php bin/phpunit

It's the same thing... except it lets symfony add the DATABASE_URL environment variable. And now... we see the dump again! Scroll to the top. Better! Now the error says:

Database app_test does not exist.

Test-Specific Database

Interesting. To understand what's happening, open config/packages/doctrine.yaml. Scroll down to a when@test section. This is cool: when we're in the test environment, there's a bit of config called dbname_suffix. Thanks to this, Doctrine will take our normal database name and add _test to it:

... lines 1 - 18
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
... lines 24 - 44

This next part is specific to a library called ParaTest where you can run tests in parallel. Since we're not using that, it's just an empty string and not something we need to worry about.

Anyway, that's how we end up with an _test at the end of our database name. And we want that! We don't want our dev and test environments to use the same database because it gets annoying when they run over each other's data.

By the way, if you're not using the symfony Binary and Docker setup... and you're configuring your database manually, be aware that in the test environment, the .env.local file is not read:

7 lines .env.test
# define your env variables for the test env here

The test environment is special: it skips reading .env.local and only reads .env.test. You can also create a .env.test.local for env vars that are read in the test environment but that won't be committed to your repository.

The ResetDatabaseTrait

Ok, in the test environment, we're missing the database. We could easily fix this by running:

symfony console doctrine:database:create --env=test

But that's way too much work. Instead, add one more trait to our test class: use ResetDatabase:

... lines 1 - 6
use Zenstruck\Foundry\Test\ResetDatabase;
class DragonTreasureResourceTest extends KernelTestCase
... line 11
use ResetDatabase;
... lines 13 - 20

This comes from Foundry: the library we've been using to create dummy fixtures via the factory classes. ResetDatabase is amazing. It automatically makes sure that the database is cleared before each test. So if you have two tests, your second test isn't going to mess up because of some data that the first test added.

It's also going to create the database automatically for us. Check it out. Run

symfony php bin/phpunit

again and check out the dump. That's our response! It's our beautiful JSON-LD! We don't have any items in the collection yet, but it is working.

And notice that, when we make this request, we are not sending an Accept header on the request. Remember, when we use the Swagger UI... it actually does send an Accept header that advertises that we want application/ld+json.

We can add that to our test if we want. But if we pass nothing, we get JSON-LD back because that's the default format of our API.

Next: let's properly finish this test, including seeding the database with data and learning about Browser's API assertions.

Leave a comment!

Login or Register to join the conversation


I tried to do the API Part 3 directly but on 2nd chapter I got an error with tests running I couldn't handled. So I went back on tutorials to this one. I started to do it and the first time I ran phpunit, Bang, same error again. Here it is :

Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: You have requested a non-existent service "test.service_container". Did you mean this: "service_container"?

Can't find real help on google. The talk about a directory "test" in config/packages but I don't have one.

Any tip ?

Kind Regard,

1 Reply

Hey @Eric-J

Sorry for my late reply. It seems your tests are not running in the "test" environment. I'd need to look at your test code. What happens if you boot the kernel manually at the beginning of the test? self::bootKernel();

Jay Avatar

If I throw a custom Api Platform exception, It should return 400. But it returns a symfony error 500.

---- github/api-platform/core/issues/3239

After some digging, I found that api-platform only handles the exception if it's a route managed by the framework, in my case I was throwing the exception from a custom controller.

So, try adding either a $request->attributes->set( '_api_respond', true); at the beginning of your method (after injecting the Request), or a defaults={"_api_respond": true} in your @Route annotation (or defaults: ['_api_respond' => true] in your #[Route] attribute)

1 Reply
sadikoff Avatar sadikoff | SFCASTS | Jay | posted 6 months ago | edited

hey @Jay

Thanks for digging, I hope it will be helpful, however does it relates to current chapter or maybe some other?


Jay Avatar
Jay Avatar Jay | sadikoff | posted 6 months ago | edited

I'm not sure. It's defiantly related to the test system.
I just hope @weaverryan will mention this some where in the course.


so it's already mentioned here in comments, but if you will find a chapter where we can apply it then it will be a better win, until it will be mentioned here in comments ;)


Manuel-B Avatar
Manuel-B Avatar Manuel-B | posted 2 days ago

Just in case anyone else stumbles over this: I had to name my local test env file env.test.local(instead of env.local.test).


You're right - I totally messed that up! Fixed the script at least in https://github.com/SymfonyCasts/api-platform3/commit/02f7a9ab05f872aa1d741335c8918f1f693bc4ef


1 Reply
Seb33300 Avatar
Seb33300 Avatar Seb33300 | posted 1 month ago | edited

Is there any reason to use zenstruck/browser instead of symfony/panther?
(I've never used both of them but my first thought would have been to choose Panther to stay in the Symfony ecosystem)


Yo @Seb33300!

Fair question! I just REALLY like the user experience from anything built by zenstruck. The fluid browser interface, for me, is more friendly than the native Symfony way of doing things. Also, the lead dev behind the zenstruck stuff is on the Symfony core team - so you're not straying too far from the ecosystem ;).


Peter-P Avatar

is there any setup to IDE quick start buttons? My project runs with symfony cli, and i am not realy able to setup phpstorm to prefix phpunit with sysmfony.


Hey @Peter-P

I'm afraid I don't fully understand your problem. If you need PHPUnit autocompletion, you can install the "PHPUnit enhancement" plugin, and you can even make PhpStorm to run your tests (setting it up depends on your local environment)


Peter-P Avatar
Peter-P Avatar Peter-P | MolloKhan | posted 2 months ago | edited

Hey @MolloKhan

thanks for the quick reply. My question is there a way to use ide build in run test feature ctrl+shift-f10 on windows? I can setup phpstorm to run tests, but as i use symfony cli as a web server, phpunit cannot find out the port for database connection.

i hope it clarify my question.



Yes, you can configure PhpStorm to run your tests just like that, but the config depends entirely on your local environment. In my case, I use WSL Ubuntu under the hood, so I had to configure PhpStorm to work with WSL by setting up a WSL interpreter. I recommend you to take a look at JetBrains docs, they have very good docs and cover almost all of the cases


Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted 3 months ago | edited

Hey, I've experienced some strange behaviors with my Database Tests. Next to my tests/Functional I've created some Integration tests in tests/Integration and created a new DatabaseTestCase that sole purpose is to use ResetDatabase and a convenience function getEntityManager(). My ApiTestCase (and so all my FunctionalTests) extends from DatabaseTestCase.

Now, all my API tests uses ZenstruckFoundry to set up my test case, it works without any problem. When I try the same with an Integration test to test a common Service (that only extends DatabaseTestCase) that tests my BudgetCategoryService::createBudgetCategoriesForEvent(Event $event), a method that creates new entities, adds them to the $event and also persists them (no cascade persist), I get

 * A new entity was found through the relationship 'App\Entity\BudgetCategory#event' that was not configured to
 cascade persist operations for entity: App\Entity\Event@1673. To solve this issue: Either explicitly call
 EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for
 example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement
 'App\Entity\Event#__toString()' to get a clue.

But the event was created with $event = EventFactory::createOne([...])->object();
When I persist everything manually beforehand, it looks like it works, but in the end, I get lots of duplicate keys.
It seems like there are 2 different entity managers, but I am not sure and don't know why


Hey @Sebastian-K!

Sorry fro the slow reply! And yikes, this indeed sounds like a mystery/mess. Because of how odd this is, my first instinct is to look for some sort of misconfiguration. Do you have use Factories at the top of your test class (or base class)? I had forgotten that once (actually, maybe in this tutorial) and thing worked fine for along time... until they didn't. I caught and fixed that in the code before recording episode 3 actually.

Anyway, let's start by checking that part.


Sebastian-K Avatar

Yeah, that solved my problem. Thanks a lot!!! I've now added use Factories to my DatabaseTestCase but I don't understand why my 10 Api Tests works without a problem, they also use Zenstruck Factories


but I don't understand why my 10 Api Tests works without a problem, they also use Zenstruck Factories

I wondered about that exact thing. The system is complicated - so a bit of a mystery :)

Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0