Buy Access to Course
30.

Testing Part 2: Functional Testing

|

Share this awesome video!

|

Keep on Learning!

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

Login Subscribe

Welcome 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:

37 lines | tests/Functional/VoyageControllerTest.php
// ... 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():

37 lines | tests/Functional/VoyageControllerTest.php
// ... 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:

37 lines | tests/Functional/VoyageControllerTest.php
// ... 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:

42 lines | phpunit.xml.dist
// ... 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>

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():

38 lines | tests/Functional/VoyageControllerTest.php
// ... 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():

39 lines | tests/Functional/VoyageControllerTest.php
// ... 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:

16 lines | tests/router.php
// ... 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:

43 lines | phpunit.xml.dist
// ... 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:

42 lines | tests/Functional/VoyageControllerTest.php
// ... 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:

17 lines | tests/AppBrowser.php
// ... 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:

44 lines | phpunit.xml.dist
// ... 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));:

43 lines | tests/Functional/VoyageControllerTest.php
// ... 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:

19 lines | tests/AppPantherTestCase.php
// ... 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:

37 lines | tests/Functional/VoyageControllerTest.php
// ... 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():

36 lines | tests/Functional/VoyageControllerTest.php
// ... 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:

99 lines | templates/base.html.twig
<!DOCTYPE html>
<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:

40 lines | tests/AppBrowser.php
// ... 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():

37 lines | tests/Functional/VoyageControllerTest.php
// ... 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:

39 lines | tests/Functional/VoyageControllerTest.php
// ... 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.