Testing Part 2: Functional Testing
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWelcome back to part 2 of day 29. I bent the rules today and made it a double feature. We talked about testing Twig & Live components... but we also need to talk about functional - or end-to-end - testing in general. That's where we programmatically control a browser, have it click links, fill out forms, etc.
Two things about this. First, we're going to create a system that I really like. And second, the road to get there is going to be... honestly, a bit bumpy. It's not a smooth process and that's something we as a community should work on.
zenstruck/browser
Symfony has built-in functional testing tools, but I like to use another library. At your terminal, install it with:
composer require zenstruck/browser --dev
Next, in the tests/
folder, I'll create a new directory called Functional/
... then a new class called VoyageControllerTest
. And I guess I could put that into a Controller/
directory also.
For the guts, I'll paste in a finished test:
// ... lines 1 - 2 | |
namespace App\Tests\Functional; | |
use App\Factory\PlanetFactory; | |
use App\Factory\VoyageFactory; | |
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; | |
use Zenstruck\Browser\Test\HasBrowser; | |
use Zenstruck\Foundry\Test\Factories; | |
use Zenstruck\Foundry\Test\ResetDatabase; | |
class VoyageControllerTest extends WebTestCase | |
{ | |
use ResetDatabase; | |
use Factories; | |
use HasBrowser; | |
public function testCreateVoyage() | |
{ | |
PlanetFactory::createOne([ | |
'name' => 'Earth', | |
]); | |
VoyageFactory::createOne(); | |
$this->browser() | |
->visit('/') | |
->click('Voyages') | |
->click('New Voyage') | |
->fillField('Purpose', 'Test voyage') | |
->selectFieldOption('Planet', 'Earth') | |
->click('Save') | |
->assertElementCount('table tbody tr', 2) | |
->assertSee('Bon voyage') | |
; | |
} | |
} |
Ok, we're using ResetDatabase
and Factories
... it extends the normal WebTestCase
for functional tests... and then HasBrowser
comes from the Browser library and gives us the ability to call $this->browser()
to control a browser with this really smooth API. This goes through the flow of going to the voyage page, clicking "New voyage", filling out the form, saving and asserting at the bottom. The test starts with a single Voyage
in the database, so after we create a new one, we assert that there are two on the page.
To run this, use the same command, but target the Functional/
directory:
symfony php vendor/bin/simple-phpunit tests/Functional
And... it actually passes! Sweet!
Testing JavaScript with Panther
But hold your horses. Behind the scenes, this is not using a real browser: it's just making fake requests in PHP. That means it doesn't execute JavaScript. We're testing the experience a user would have if they had JavaScript disabled. That's fine for many situations. However, this time, I want to test all the modal fanciness.
To run the test using a real browser that supports JavaScript - like Chrome - change to $this->pantherBrowser()
:
// ... lines 1 - 11 | |
class VoyageControllerTest extends WebTestCase | |
{ | |
// ... lines 14 - 17 | |
public function testCreateVoyage() | |
{ | |
// ... lines 20 - 24 | |
$this->pantherBrowser() | |
// ... lines 26 - 33 | |
; | |
} | |
} |
Try it:
symfony php vendor/bin/simple-phpunit tests/Functional
No dice! But a nice error: we need to install symfony/panther
. Let's do that!
composer require symfony/panther --dev
Panther is a PHP library that can programmatically control real browsers on your machine. To use it, we also need to extend PantherTestCase
:
// ... lines 1 - 6 | |
use Symfony\Component\Panther\PantherTestCase; | |
// ... lines 8 - 11 | |
class VoyageControllerTest extends PantherTestCase | |
{ | |
// ... lines 14 - 35 | |
} |
Try it again:
symfony php vendor/bin/simple-phpunit tests/Functional
We don't see the browser - it opens invisibly in the background - but it's now using Chrome! And the test fails - pretty early:
Clickable element "New Voyage" not found.
Hmm. It clicked "Voyages", but didn't find the "New Voyage" button. A fantastic feature of zenstruck/browser
with Panther is that, when a test fails, it takes a screenshot of the failure.
Inside the var/
directory... here it is. Huh, the screenshot shows that we're still on the homepage - as if we never clicked "Voyages"... though you can kind of see that the voyages link looks active.
The problem is that the page navigation happens via Ajax... and our tests don't know to wait for that to finish. It clicks "Voyages"... then immediately tries to click "New Voyage". This will be the main thing that we need to fix.
Loading a "test" Dev Server
But before that, I see a bigger problem! Look at the data: this is not coming from our test database! This is coming from our dev site!
Even though we can't see it, Panther is controlling a real browser. And... a real browser needs to access our site using a real web server via a real web address. Because we're using the Symfony web server, Panther detected that and... used it!
But... that's not what we want! Why? Our server is using the dev
environment and the dev
database. Our tests should use the test
environment and the test
database.
To fix this, open up phpunit.xml.dist
. I'll paste in two environment variables:
// ... lines 1 - 3 | |
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
// ... lines 5 - 9 | |
> | |
<php> | |
// ... lines 12 - 17 | |
<server name="SYMFONY_PROJECT_DEFAULT_ROUTE_URL" value="" /> | |
<server name="PANTHER_APP_ENV" value="test" /> | |
</php> | |
// ... lines 21 - 40 | |
</phpunit> |
Tip
As of zenstruck/browser
1.9.0, this hack is no longer required. You can safely ignore adding these lines to your phpunit.xml.dist
.
The first... is kind of a hack. That tells Panther to not use our server. Instead, Panther will now silently start its own web server using the built-in PHP web server. The second line tells Panther to use the test
environment when it does that.
Over in the test, to make it even easier to see if this is working, after we click voyages, call ddScreenshot()
:
// ... lines 1 - 11 | |
class VoyageControllerTest extends PantherTestCase | |
{ | |
// ... lines 14 - 17 | |
public function testCreateVoyage() | |
{ | |
// ... lines 20 - 24 | |
$this->pantherBrowser() | |
->visit('/') | |
->click('Voyages') | |
->ddScreenshot() | |
// ... lines 29 - 34 | |
; | |
} | |
} |
Take a screenshot, then dump and die.
Run it:
symfony php vendor/bin/simple-phpunit tests/Functional
It hits that... and saved a screenshot! Cool! Find that in var/
. And... ok. It looks like the new web server is being used... but it's missing all the styles!
Debugging by Opening the Browser
Time for some detective work! To understand what's going on, we can temporarily tell Panther to actually open the browser, like, so we can see it and play with it.
After we visit, say ->pause()
:
// ... lines 1 - 11 | |
class VoyageControllerTest extends PantherTestCase | |
{ | |
// ... lines 14 - 17 | |
public function testCreateVoyage() | |
{ | |
// ... lines 20 - 24 | |
$this->pantherBrowser() | |
->visit('/') | |
->pause() | |
// ... lines 28 - 35 | |
; | |
} | |
} |
Then, to open the browser, prefix the test command with PANTHER_NO_HEADLESS=1
:
PANTHER_NO_HEADLESS=1 symfony php vendor/bin/simple-phpunit tests/Functional
And... woh! It popped up the browser then paused. Now we can view the page source. Here's the CSS file. Open that. It's a 404 not found. Why?
In the dev environment, our assets are served through Symfony: they're not real, physical files. If you prefix the URL with index.php
, it works. Panther uses the built-in PHP web server... and it needs a rewrite rule that tells it to send these URLs through Symfony. Honestly, it's an annoying detail, but we can fix it.
Back at the terminal, hit enter to close the browser. In tests/
, create a new file called router.php
. I'll paste in the code:
// ... lines 1 - 2 | |
if (is_file($_SERVER['DOCUMENT_ROOT'].\DIRECTORY_SEPARATOR.$_SERVER['SCRIPT_NAME'])) { | |
return false; | |
} | |
$script = 'index.php'; | |
$_SERVER = array_merge($_SERVER, $_ENV); | |
$_SERVER['SCRIPT_FILENAME'] = $_SERVER['DOCUMENT_ROOT'].\DIRECTORY_SEPARATOR.$script; | |
$_SERVER['SCRIPT_NAME'] = \DIRECTORY_SEPARATOR.$script; | |
$_SERVER['PHP_SELF'] = \DIRECTORY_SEPARATOR.$script; | |
require $script; |
This is a "router" file that will be used by the built-in web server. To tell Panther to use it, in phpunit.xml.dist
, I'll paste in another env var: PANTHER_WEB_SERVER_ROUTER
set to ../tests/router.php
:
// ... lines 1 - 3 | |
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
// ... lines 5 - 9 | |
> | |
<php> | |
// ... lines 12 - 19 | |
<server name="PANTHER_WEB_SERVER_ROUTER" value="../tests/router.php" /> | |
</php> | |
// ... lines 22 - 41 | |
</phpunit> |
Try it!
PANTHER_NO_HEADLESS=1 symfony php vendor/bin/simple-phpunit tests/Functional
And now... it works! Hit enter to finish. Then remove the pause()
.
Run the test again, but without the env var:
symfony php vendor/bin/simple-phpunit tests/Functional
Waiting for the Turbo Page Load
Cool: it hit our screenshot line. Pop that open. Ok, we're back to the original problem: it's not waiting for the page to load after we click the link.
Solving this... isn't as simple as it should be. Say $browser =
, close that and start a new chain with $browser
below. In between, I'll paste in two lines. This is lower-level, but waits for the aria-busy
attribute to be added to the html
element, which Turbo does when it's loading. Then it waits for it to go away:
// ... lines 1 - 11 | |
class VoyageControllerTest extends PantherTestCase | |
{ | |
// ... lines 14 - 17 | |
public function testCreateVoyage() | |
{ | |
// ... lines 20 - 24 | |
$browser = $this->pantherBrowser() | |
->visit('/') | |
->click('Voyages') | |
; | |
$browser->client()->waitFor('html[aria-busy="true"]'); | |
$browser->client()->waitFor('html:not([aria-busy])'); | |
$browser | |
->ddScreenshot() | |
->click('New Voyage') | |
->fillField('Purpose', 'Test voyage') | |
->selectFieldOption('Planet', 'Earth') | |
->click('Save') | |
->assertElementCount('table tbody tr', 2) | |
->assertSee('Bon voyage') | |
; | |
} | |
} |
Try the test now:
symfony php vendor/bin/simple-phpunit tests/Functional
Then... pop open the screenshot. Woh! It is now waiting for the Ajax call to finish. But remember: we're also using view transitions. The page loaded... but it's still in the middle of the transition. We'll fix that in a minute.
Custom Browser & Base Test Class
But first, we need to clean this up: this is way too much work. What I would love is a new method on the browser itself - like waitForPageLoad()
. And we can do that with a custom browser class!
In the tests/
directory, create a new class called AppBrowser
. I'll paste in the guts:
// ... lines 1 - 2 | |
namespace App\Tests; | |
use Zenstruck\Browser\PantherBrowser; | |
class AppBrowser extends PantherBrowser | |
{ | |
public function waitForPageLoad(): self | |
{ | |
$this->client()->waitFor('html[aria-busy="true"]'); | |
$this->client()->waitFor('html:not([aria-busy])'); | |
return $this; | |
} | |
} |
This extends the normal PantherBrowser
and adds a new method which those same two lines.
When we call $this->pantherBrowser()
, we now want it to return our AppBrowser
instead of the normal PantherBrowser
. To do that, you guessed it, it's an environment variable: PANTHER_BROWSER_CLASS
set to App\Tests\AppBrowser
:
// ... lines 1 - 3 | |
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
// ... lines 5 - 9 | |
> | |
<php> | |
// ... lines 12 - 20 | |
<server name="PANTHER_BROWSER_CLASS" value="App\Tests\AppBrowser" /> | |
</php> | |
// ... lines 23 - 42 | |
</phpunit> |
To make sure this is working, dd(get_class($browser));
:
// ... lines 1 - 11 | |
class VoyageControllerTest extends PantherTestCase | |
{ | |
// ... lines 14 - 17 | |
public function testCreateVoyage() | |
{ | |
// ... lines 20 - 24 | |
$browser = $this->pantherBrowser() | |
->visit('/') | |
->click('Voyages') | |
; | |
dd(get_class($browser)); | |
// ... lines 30 - 40 | |
} | |
} |
Run the test:
symfony php vendor/bin/simple-phpunit tests/Functional
And... yes! We get AppBrowser
! Unfortunately, while the new method would work, we don't get autocompletion. Our editor has no idea that we swapped in a sub-class.
To improve this, let's do one last thing: in tests/
, create a new base test class: AppPantherTestCase
. I'll paste in the content:
// ... lines 1 - 2 | |
namespace App\Tests; | |
use Symfony\Component\Panther\PantherTestCase; | |
use Zenstruck\Browser\Test\HasBrowser; | |
class AppPantherTestCase extends PantherTestCase | |
{ | |
use HasBrowser { | |
pantherBrowser as parentPantherBrowser; | |
} | |
protected function pantherBrowser(array $options = [], array $kernelOptions = [], array $managerOptions = []): AppBrowser | |
{ | |
return $this->parentPantherBrowser($options, $kernelOptions, $managerOptions); | |
} | |
} |
It extends the normal PantherTestCase
... then overrides the pantherBrowser()
method, calls the parent, but changes the return type to be our AppBrowser
.
Over in VoyageControllerTest
, change this to extend
AppPantherTestCase
, then make sure to remove use HasBrowser
:
// ... lines 1 - 6 | |
use App\Tests\AppPantherTestCase; | |
// ... lines 8 - 10 | |
class VoyageControllerTest extends AppPantherTestCase | |
{ | |
use ResetDatabase; | |
use Factories; | |
// ... lines 16 - 35 | |
} |
Then we can tighten things up: reconnect all of these spots... then use the new method: ->waitForPageLoad()
... with auto-complete! Remove the ddScreenshot()
:
// ... lines 1 - 10 | |
class VoyageControllerTest extends AppPantherTestCase | |
{ | |
// ... lines 13 - 15 | |
public function testCreateVoyage() | |
{ | |
// ... lines 18 - 22 | |
$this->pantherBrowser() | |
->visit('/') | |
->click('Voyages') | |
->waitForPageLoad() | |
->click('New Voyage') | |
->fillField('Purpose', 'Test voyage') | |
->selectFieldOption('Planet', 'Earth') | |
->click('Save') | |
->assertElementCount('table tbody tr', 2) | |
->assertSee('Bon voyage') | |
; | |
} | |
} |
And let's see where we are!
symfony php vendor/bin/simple-phpunit tests/Functional
Further!
Form field "Purpose" not found.
So it clicked Voyages, clicked "New Voyage"... but couldn't find the form field. If we look down at the error screenshot, we can see why: the modal content is still loading! You might see the form in your screenshot - sometimes the screenshot happens just a moment later, so the form is visible - but this is the problem.
Disabling View Transitions
Oh, but before we fix this, I also want to disable view transitions. In templates/base.html.twig
, the easiest way to make sure view transitions don't muck up our tests is to remove them. Say if app.environment != 'test
, then render this meta
tag:
<html> | |
<head> | |
// ... lines 4 - 6 | |
{% if app.environment != 'test' %} | |
<meta name="view-transition"> | |
{% endif %} | |
// ... lines 10 - 16 | |
</head> | |
// ... lines 18 - 97 | |
</html> |
Waiting for the Modal to Load
Anyway, back to our failure. When we click to open the modal, what need wait for the modal to open - that's actually instant - but also wait for the <turbo-frame>
inside to finish loading.
Open AppBrowser
. I'll paste in two more methods:
// ... lines 1 - 4 | |
use Facebook\WebDriver\WebDriverBy; | |
// ... lines 6 - 7 | |
class AppBrowser extends PantherBrowser | |
{ | |
// ... lines 10 - 17 | |
public function waitForDialog(): self | |
{ | |
$this->client()->wait()->until(function() { | |
return $this->crawler()->filter('dialog[open]')->count() > 0; | |
}); | |
if ($this->crawler()->filter('dialog[open] turbo-frame')->count() > 0) { | |
$this->waitForTurboFrameLoad(); | |
} | |
return $this; | |
} | |
public function waitForTurboFrameLoad(): self | |
{ | |
$this->client()->wait()->until(function() { | |
return $this->crawler()->filter('turbo-frame[aria-busy="true"]')->count() === 0; | |
}); | |
return $this; | |
} | |
} |
The first - waitForDialog()
- waits until it sees a dialog on the page with an open
attribute. And, if that open dialog
has a <turbo-frame>
, it waits for that to load: it waits until there aren't any aria-busy
frames on the page.
In VoyageControllerTest
, after clicking "New Voyage", say ->waitForDialog()
:
// ... lines 1 - 10 | |
class VoyageControllerTest extends AppPantherTestCase | |
{ | |
// ... lines 13 - 15 | |
public function testCreateVoyage() | |
{ | |
// ... lines 18 - 22 | |
$this->pantherBrowser() | |
// ... lines 24 - 26 | |
->click('New Voyage') | |
->waitForDialog() | |
->fillField('Purpose', 'Test voyage') | |
// ... lines 30 - 33 | |
; | |
} | |
} |
And now:
symfony php vendor/bin/simple-phpunit tests/Functional
So close!
table
tbody
tr
expected 2 elements on the page but only found 1.
That comes from all the way down here! What's the problem this time? Back to the error screenshot! Ah: we filled out the form, it looks like we even hit Save... but we're asserting too quickly!
Remember: this submits into to a <turbo-frame>
, so we need to wait for that frame to finish loading. And we have a way to do this: ->waitForTurboFrameLoad()
. I'll also add a line to assert that we cannot see any open dialogs: to check that the modal closed:
// ... lines 1 - 10 | |
class VoyageControllerTest extends AppPantherTestCase | |
{ | |
// ... lines 13 - 15 | |
public function testCreateVoyage() | |
{ | |
// ... lines 18 - 22 | |
$this->pantherBrowser() | |
->visit('/') | |
->click('Voyages') | |
->waitForPageLoad() | |
->click('New Voyage') | |
->waitForDialog() | |
->fillField('Purpose', 'Test voyage') | |
->selectFieldOption('Planet', 'Earth') | |
->click('Save') | |
->waitForTurboFrameLoad() | |
->assertElementCount('table tbody tr', 2) | |
->assertNotSeeElement('dialog[open]') | |
->assertSee('Bon voyage') | |
; | |
} | |
} |
Run the test one more time:
symfony php vendor/bin/simple-phpunit tests/Functional
It passes. Woo! I admit, that was some work, too much work! But I do love the end result.
Tomorrow - for our final day - we're going to talk about performance. And unlike today, things are going to quickly fall into place - I promise.
Right after
composer require symfony/panther --dev
I had to runcomposer require --dev dbrekelmans/bdi && vendor/bin/bdi detect drivers