Test Environment Database Setup
This first test was too easy! So let's write another, more interesting one. How about, ahem, public function
testIsInLockDownReturnsTrueIfMostRecentLockdownIsActive(). Phew!
Start the same as before: self::bootKernel()
. The tricky thing about this test is that we need the database to not be empty at the start. We need to insert an active lockdown into the database... so that when we finally call the method and it executes the query, it will find the record.
// ... lines 1 - 7 | |
class LockDownRepositoryTest extends KernelTestCase | |
{ | |
// ... lines 10 - 18 | |
public function testIsInLockDownReturnsTrueIfMostRecentLockDownIsActive() | |
{ | |
self::bootKernel(); | |
} | |
} |
This is a common part of integration tests since they frequently talk to the database.
Seeding the Database
No problem! Let's create a lock down! Add $lockDown = new LockDown()
, $lockDown->setReason()
so we know why the lockdown is happening, and $lockDown->setCreatedAt()
to, how about, 1 day ago. That part isn't super important yet. Oh, and we don't need to set the status because, if you look in the class, it defaults to ACTIVE
.
// ... lines 1 - 20 | |
public function testIsInLockDownReturnsTrueIfMostRecentLockDownIsActive() | |
{ | |
// ... lines 23 - 24 | |
$lockDown = new LockDown(); | |
$lockDown->setReason('Dinos have organized their own lunch break'); | |
$lockDown->setCreatedAt(new \DateTimeImmutable('-1 day')); | |
// ... lines 28 - 34 | |
} | |
// ... lines 36 - 37 |
Saving this is simple too. Grab the $entityManager
with self::getContainer()->get(EntityManagerInterface::class)
. And I'll do our assert()
trick with $entityManager instanceof EntityManagerInterface
to help my editor. Finish with the usual $entityManager->persist($lockDown)
and $entityManager->flush()
.
To see if this is working, down here, dd($lockDown->getId())
.
// ... lines 1 - 20 | |
public function testIsInLockDownReturnsTrueIfMostRecentLockDownIsActive() | |
{ | |
// ... lines 23 - 28 | |
$entityManager = self::getContainer()->get(EntityManagerInterface::class); | |
assert($entityManager instanceof EntityManagerInterface); | |
$entityManager->persist($lockDown); | |
$entityManager->flush(); | |
dd($lockDown->getId()); | |
} | |
// ... lines 36 - 37 |
Let's try it! Run just the tests from this file:
./vendor/bin/phpunit tests/Integration/Repository/LockDownRepositoryTest.php
And... oh... it explodes. Let's see... Ah! It's having trouble connecting to the database!
Forgetting about tests for a moment, this is a familiar problem! The key to connecting our app to the database is the DATABASE_URL
environment variable. I'm using Postgres, but that doesn't matter.
Special .env handling for Tests
Normally, when we set up our local environment, we customize DATABASE_URL
here in .env
... or we create a .env.local
file and override it there.
// ... lines 1 - 31 |
And, in general, when we boot the kernel in our tests, everything works exactly the same as loading our app in the browser. It does boot our code in a Symfony environment called test
instead of dev
... and that does change a few things. But 99% of the behavior is the same.
If you look at the error, the test is having problems connecting to 127.0.0.1
at port 5432
. That makes sense: it's reading that from our .env
file. All very normal.
But, there is one important difference in the test
environment. If you create a .env.local
file, override DATABASE_URL
, and run your tests (I'll change this port to something crazy like 9999
), it won't be used! Check out this error! It's still looking for port 5432
.
In the test
environment only, the .env.local
file is not loaded. So if you want to configure a DATABASE_URL
specifically for your test
environment, you need to put it into .env.test
: the environment-specific variable file.
Before we move on, make sure to delete that .env.local
file to avoid any confusion.
Reading from Docker in your Tests
But in our case, we're not going to rely on any of these .env
files. That's because, if you followed the README.md
instructions, we're using Docker behind the scenes. We have a docker-compose.yaml
file, which starts a Postgres database. And because we're using the Symfony binary as a web server, it sets the DATABASE_URL
automatically to point to that container.
When we refresh the page... it's not using the DATABASE_URL
from my .env
: it's using the dynamic value that's set by the symfony
binary. This is something that we talked about in our Doctrine tutorial.
However, that magic is clearly not happening in our test! The error makes it obvious that it's looking at the DATABASE_URL
from .env
. And... that's true! This is because the symfony
binary doesn't have a chance to inject the DATABASE_URL
environment variable. To allow that, instead of running ./vendor/bin/phpunit
, run symfony php vendor/bin/phpunit
... followed by the path to the test
symfony php vendor/bin/phpunit tests/Integration/Repository/LockDownRepositoryTest.php
The symfony php
command is just a way to execute PHP... but by doing this, it lets the symfony
binary work its magic.
When we try this... it fails again. But check it out! This is a different error. Now it's talking about port 58292
. That's the random port that my Docker database can apparently be reached on. It also says database "app_test" does not exist
.
Automatically Suffixed Test Databases
To see what that's about, run:
symfony var:export --multiline
This shows all the environment variables that the Symfony binary is injecting. The most important is DATABASE_URL
. This points at the Docker container... which for me, is running on port 58292
.
The key detail is this app
part. That's the name of the database that should be used. So if DATABASE_URL
is pointing to a database named app
, why did the error say that a database called app_test
doesn't exist?
Before we answer that, I have another question: when we run our tests, do we want them to use the same database that our local app is using? Ideally, no! Having a different database for your tests versus your normal development environment is a good idea. For one... it's just annoying to run your tests and have it manipulate your data while developing. And fortunately, having two different databases is something that happens automatically.
Open config/packages/doctrine.yaml
. Down at the bottom, we have this special when@test
block. This is config only for the test
environment. And check out that dbname_suffix
! It's set to _test
. You can ignore the %env(default::TEST_TOKEN)%
bit. That relates to a library called ParaTest and, in our case, it will be empty. So effectively, it's just _test
.
// ... lines 1 - 23 | |
when@test: | |
doctrine: | |
dbal: | |
# "TEST_TOKEN" is typically set by ParaTest | |
dbname_suffix: '_test%env(default::TEST_TOKEN)%' | |
// ... lines 29 - 49 |
So thanks to this config, in the test
environment, it takes the app
config, adds _test
to it and ultimately uses a database called app_test
.
That's really nice! And now that we understand that, all we need to do is create that database.
Creating the Database
At your terminal, run symfony console
- this is just bin/console
, but allows the symfony
binary to inject the DATABASE_URL
environment variable - doctrine:database:create --env=test
:
symfony console doctrine:database:create --env=test
And... success!! We also need to create the schema
: doctrine:schema:create
symfony console doctrine:schema:create --env=test
Cool! Try the test now:
symfony php vendor/bin/phpunit tests/Integration/Repository/LockDownRepositoryTest.php
It worked! That 1
... comes from the dump down here.
Finishing the Query
Let's finish this test. To make life easier, copy the repository line, then create a new private method: private function getLockDownRepository()
. Paste, add return
, then the return type. Now we don't need the assert()
because PHP will throw a big error if this returns something else for some reason.
// ... lines 1 - 9 | |
class LockDownRepositoryTest extends KernelTestCase | |
{ | |
// ... lines 12 - 34 | |
private function getLockDownRepository(): LockDownRepository | |
{ | |
return self::getContainer()->get(LockDownRepository::class); | |
} | |
} |
Simplify things up here with $this->getLockDownRepository()->isInLockDown()
.
// ... lines 1 - 11 | |
public function testIsInLockDownReturnsFalseWithNoRows() | |
{ | |
self::bootKernel(); | |
$this->assertFalse($this->getLockDownRepository()->isInLockDown()); | |
} | |
// ... lines 18 - 40 |
Try the test again to make sure it still passes...
symfony php vendor/bin/phpunit tests/Integration/Repository/LockDownRepositoryTest.php
It does. And interestingly, the ID is now 2
. More on that soon.
Replace the dump with $this->assertTrue()
that $this->getLockDownRepository()->isInLockDown()
.
// ... lines 1 - 18 | |
public function testIsInLockDownReturnsTrueIfMostRecentLockDownIsActive() | |
{ | |
// ... lines 21 - 31 | |
$this->assertTrue($this->getLockDownRepository()->isInLockDown()); | |
} | |
// ... lines 34 - 40 |
Over in the repository, I'll paste in the real query. This looks for a lockdown that has not ended, and returns true or false.
// ... lines 1 - 17 | |
class LockDownRepository extends ServiceEntityRepository | |
{ | |
// ... lines 20 - 24 | |
public function isInLockDown(): bool | |
{ | |
return $this->createQueryBuilder('lock_down') | |
->andWhere('lock_down.status != :endedStatus') | |
->setParameter('endedStatus', LockDownStatus::ENDED) | |
->setMaxResults(1) | |
->getQuery() | |
->getOneOrNullResult() !== null; | |
} | |
} |
Let's do this!
symfony php vendor/bin/phpunit tests/Integration/Repository/LockDownRepositoryTest.php
And... the test fails? Oh, our second test passed, but the original test is suddenly failing. How did that happen?
It turns out, thanks to the second test, when the first test runs, the database is no longer empty. In fact, it's piling up with more and more rows each time we run the tests. Watch, run:
symfony console dbal:run-sql 'SELECT * FROM lock_down' --env=test
Yikes! This is a critical problem: we need to guarantee that the database is in a predictable state at the beginning of every test. Let's dive into this very important problem next.
The
app
you highlighted is the user, the database name is theapp
after the port and the slash ;)