Buy Access to Course
29.

Testing Part 1: Twig & Live Components

|

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

All these nifty gadgets that we've built are just toys, unless we can test them. So, that's today's mission! Tons to tackle, so let's jump right in!

Run:

composer require phpunit

That installs the symfony/test-pack, gives us all the packages we need and puts them into require-dev.

Testing a Twig Component

For our first act, let's test a Twig Component. This is pretty cool: we can create the component object, call methods on it and assert how it's rendered, all in isolation. It's simple, but we'll test the Button component.

In the tests/ directory, create an Integration/ directory - because this will be an integration test - then Twig/Components/. If you're new to integration tests, check our Integration Testing tutorial.

Inside, create a new ButtonTest class... and extend the normal KernelTestCase for integration tests:

// ... lines 1 - 2
namespace App\Tests\Integration\Twig\Components;
// ... lines 4 - 5
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
// ... lines 7 - 8
class ButtonTest extends KernelTestCase
{
// ... lines 11 - 21
}

To help us work with the component, use a trait called InteractsWithTwigComponents, then add a new function: testButtonRendersWithVariants():

// ... lines 1 - 6
use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents;
class ButtonTest extends KernelTestCase
{
use InteractsWithTwigComponents;
public function testButtonRendersWithVariants()
{
// ... lines 15 - 20
}
}

Mounting the Component

The trait gives us two methods. The first lets us create the component object. Say $this->mountTwigComponent() passing the component name Button and any props, like variant set to success.

This should give us a Button: assertInstanceOf, Button::class, $component. Dump $component then assertSame that success is equal to $component->variant:

// ... lines 1 - 8
class ButtonTest extends KernelTestCase
{
// ... lines 11 - 12
public function testButtonRendersWithVariants()
{
$component = $this->mountTwigComponent('Button', [
'variant' => 'success',
]);
dump($component);
$this->assertInstanceOf(Button::class, $component);
$this->assertSame('success', $component->variant);
}
}

Cool! To try this, run:

./vendor/bin/simple-phpunit tests/Integration

That'll download PHPUnit, and... it passes! We have some deprecation notices, but ignore those.

Rendering the Component

The second thing we can do is render a component. Copy the top, paste on the bottom, rename this to $rendered and call renderTwigComponent(). This has almost the same arguments, but we can also pass blocks. The third argument is a shortcut to pass the content block.

Dump $rendered:

// ... lines 1 - 8
class ButtonTest extends KernelTestCase
{
// ... lines 11 - 12
public function testButtonRendersWithVariants()
{
$component = $this->mountTwigComponent('Button', [
'variant' => 'success',
]);
$this->assertInstanceOf(Button::class, $component);
$this->assertSame('success', $component->variant);
$rendered = $this->renderTwigComponent('Button', [
'variant' => 'success',
], '<span>Click me!</span>');
dump($rendered);
}
}

And let's see what this looks like!

./vendor/bin/simple-phpunit tests/Integration

Awesome! An object with the HTML inside. With this, we can get the raw string... or we can access a Crawler object. This is cool: $this->assertSame() that Click Me!, is equal to $rendered->crawler()->filter() - to find the span - then ->text():

// ... lines 1 - 8
class ButtonTest extends KernelTestCase
{
// ... lines 11 - 12
public function testButtonRendersWithVariants()
{
$component = $this->mountTwigComponent('Button', [
'variant' => 'success',
]);
$this->assertInstanceOf(Button::class, $component);
$this->assertSame('success', $component->variant);
$rendered = $this->renderTwigComponent('Button', [
'variant' => 'success',
], '<span>Click me!</span>');
$this->assertSame('Click me!', $rendered->crawler()->filter('span')->text());
}
}

Super sweet! My editor's yelling 'syntax error', but it's being dramatic. Watch:

./vendor/bin/simple-phpunit tests/Integration

It passes!

Testing a Live Component

So how about integration testing a live component... like our fancy SearchSite? In the same directory, create a new class called SearchSiteTest, extend KernelTestCase and... this time use InteractsWithLiveComponents. Create a method: testCanRenderAndReload():

// ... lines 1 - 2
namespace App\Tests\Integration\Twig\Components;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;
class SearchSiteTest extends KernelTestCase
{
use InteractsWithLiveComponents;
public function testCanRenderAndReload()
{
// ... lines 14 - 15
}
}

With this trait, we can say $testComponent equals $this->createLiveComponent(). Pass the name - SearchSite... and we can also pass any props, but I won't. We'll let the $query start empty. dd($testComponent):

// ... lines 1 - 5
use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;
class SearchSiteTest extends KernelTestCase
{
use InteractsWithLiveComponents;
public function testCanRenderAndReload()
{
$testComponent = $this->createLiveComponent('SearchSite');
dd($testComponent);
}
}

When we run this:

./vendor/bin/simple-phpunit tests/Integration

The object is humongous... but it's a TestLiveComponent. And it has a ton of goodies. We can say $testComponent->component() to get the underlying component object, we can render it, and we can even mimic user behavior, like changing a model value, calling live actions, emitting events or even logging in.

Test Database Setup

To test the search, we need to add some voyages to the database. On top, use ResetDatabase and use Factories:

// ... lines 1 - 7
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class SearchSiteTest extends KernelTestCase
{
use InteractsWithLiveComponents;
use ResetDatabase;
use Factories;
// ... lines 16 - 26
}

Down here, use VoyageFactory::createMany() to create 5 voyages... and give them all the same purpose so we can easily search for them. Then create one more Voyage with any other random purpose:

// ... lines 1 - 10
class SearchSiteTest extends KernelTestCase
{
// ... lines 13 - 16
public function testCanRenderAndReload()
{
VoyageFactory::createMany(5, [
'purpose' => 'first 5 voyages',
]);
VoyageFactory::createOne();
$testComponent = $this->createLiveComponent('SearchSite');
dd($testComponent);
}
}

Before we take advantage of these, try the test again:

./vendor/bin/simple-phpunit tests/Integration

A database connection error! I'm running the database via Docker & using the symfony binary to set the DATABASE_URL environment variable. To inject that variable when running the test, prefix the command with symfony php:

symfony php vendor/bin/simple-phpunit tests/Integration

And... we're back! One risky test because we don't have any assertions. Let's add those!

Remember: if there is no query, our component returns no voyages. And in the template: templates/components/SearchSite.html.twig, when we do have results, each is an a tag.

In the test, $this->assertCount() that 0 is equal to $testComponent->render(), then use that same ->crawler() to filter for a tags.

Here's the really cool part: call $testComponent->set() query to first 5 to mimic the user typing into the search box. And now we should have 5 results:

// ... lines 1 - 10
class SearchSiteTest extends KernelTestCase
{
// ... lines 13 - 16
public function testCanRenderAndReload()
{
// ... lines 19 - 23
$testComponent = $this->createLiveComponent('SearchSite');
$this->assertCount(0, $testComponent->render()->crawler()->filter('a'));
$testComponent->set('query', 'first 5');
$this->assertCount(5, $testComponent->render()->crawler()->filter('a'));
}
}

Do it!

symfony php vendor/bin/simple-phpunit tests/Integration

Green! Ok, today is a bit unorthodox because... we're out of time... but I have more to say! Next up is part two where we take on functional tests for our JavaScript-powered frontend.