Skip to content

[Twig] add test helper #821

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

- Add new HTML syntax for rendering components: `<twig:ComponentName>`
- `true` attribute values now render just the attribute name, `false` excludes it entirely.

- Add helpers for testing components.
- The first argument to `AsTwigComponent` is now optional and defaults to the class name.
- Allow passing a FQCN to `ComponentFactory` methods.

## 2.7.0

Expand Down
55 changes: 55 additions & 0 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,61 @@ And in your component template you can access your embedded block
{% block footer %}{% endblock %}
</div>

Test Helpers
------------

You can test how your component is mounted and rendered using the
``InteractsWithTwigComponents`` trait::

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents;

class MyComponentTest extends KernelTestCase
{
use InteractsWithTwigComponents;

public function testComponentMount(): void
{
$component = $this->mountTwigComponent(
name: 'MyComponent', // can also use FQCN (MyComponent::class)
data: ['foo' => 'bar'],
);

$this->assertInstanceOf(MyComponent::class, $component);
$this->assertSame('bar', $component->foo);
}

public function testComponentRenders(): void
{
$rendered = $this->renderTwigComponent(
name: 'MyComponent', // can also use FQCN (MyComponent::class)
data: ['foo' => 'bar'],
);

$this->assertStringContainsString('bar', $rendered);
}

public function testEmbeddedComponentRenders(): void
{
$rendered = $this->renderTwigComponent(
name: 'MyComponent', // can also use FQCN (MyComponent::class)
data: ['foo' => 'bar'],
content: '<div>My content</div>', // "content" (default) block
blocks: [
'header' => '<div>My header</div>',
'menu' => $this->renderTwigComponent('Menu'), // can embed other components
],
);

$this->assertStringContainsString('bar', $rendered);
}
}

.. note::

The ``InteractsWithTwigComponents`` trait can only be used in tests that extend
``Symfony\Bundle\FrameworkBundle\Test\KernelTestCase``.

Contributing
------------

Expand Down
22 changes: 18 additions & 4 deletions src/TwigComponent/src/ComponentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,24 @@
final class ComponentFactory
{
/**
* @param array<string, array> $config
* @param array<string, array> $config
* @param array<class-string, string> $classMap
*/
public function __construct(
private ServiceLocator $components,
private PropertyAccessorInterface $propertyAccessor,
private EventDispatcherInterface $eventDispatcher,
private array $config
private array $config,
private array $classMap,
) {
}

public function metadataFor(string $name): ComponentMetadata
{
$name = $this->classMap[$name] ?? $name;

if (!$config = $this->config[$name] ?? null) {
throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->config))));
$this->throwUnknownComponentException($name);
}

return new ComponentMetadata($config);
Expand Down Expand Up @@ -142,8 +146,10 @@ private function mount(object $component, array &$data): void

private function getComponent(string $name): object
{
$name = $this->classMap[$name] ?? $name;

if (!$this->components->has($name)) {
throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->components->getProvidedServices()))));
$this->throwUnknownComponentException($name);
}

return $this->components->get($name);
Expand Down Expand Up @@ -182,4 +188,12 @@ private function postMount(object $component, array $data): array

return $data;
}

/**
* @return never
*/
private function throwUnknownComponentException(string $name): void
{
throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->config))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function process(ContainerBuilder $container): void
$componentConfig = [];

$componentReferences = [];
$componentClassMap = [];
$componentNames = [];
foreach ($container->findTaggedServiceIds('twig.component') as $id => $tags) {
$definition = $container->findDefinition($id);
Expand All @@ -52,11 +53,13 @@ public function process(ContainerBuilder $container): void
$componentConfig[$tag['key']] = $tag;
$componentReferences[$tag['key']] = new Reference($id);
$componentNames[] = $tag['key'];
$componentClassMap[$tag['class']] = $tag['key'];
}
}

$factoryDefinition = $container->findDefinition('ux.twig_component.component_factory');
$factoryDefinition->setArgument(0, ServiceLocatorTagPass::register($container, $componentReferences));
$factoryDefinition->setArgument(3, $componentConfig);
$factoryDefinition->setArgument(4, $componentClassMap);
}
}
67 changes: 67 additions & 0 deletions src/TwigComponent/src/Test/InteractsWithTwigComponents.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Test;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
trait InteractsWithTwigComponents
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is my preferred name (:wink:) but I have no problem adding the Trait suffix or naming something else that jives with Symfony standards.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am just wondering why do we use trait here? In my use case, I gonna create a dedicated test class for my component, and I not gonna mixed my components tests with other tests

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a pattern I've found useful with my other testing libraries. I like using traits to avoid forcing a single type of test.

class MyComponentTest extends KernelTestCase
{
    use InteractsWithTwigComponents, ResetDatabase, Factories;

    // ...
}

I get the above would still work if we made this trait into and abstract TwigComponentTestCase. I'm just trying to be consistent: Make a standard kernel test, and inject the test features you need via traits.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ho yes using this trait with foundry just rocks! Thanks for all of this 😁

{
protected function mountTwigComponent(string $name, array $data = []): object
{
if (!$this instanceof KernelTestCase) {
throw new \LogicException(sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class));
}

return static::getContainer()->get('ux.twig_component.component_factory')->create($name, $data)->getComponent();
}

/**
* @param array<string,string> $blocks
*/
protected function renderTwigComponent(string $name, array $data = [], ?string $content = null, array $blocks = []): RenderedComponent
{
if (!$this instanceof KernelTestCase) {
throw new \LogicException(sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class));
}

$blocks = array_filter(array_merge($blocks, ['content' => $content]));

if (!$blocks) {
return new RenderedComponent(self::getContainer()->get('twig')
->createTemplate('{{ component(name, data) }}')
->render([
'name' => $name,
'data' => $data,
])
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is pretty nuts 👍

}

$template = sprintf('{%% component "%s" with data %%}', addslashes($name));

foreach (array_keys($blocks) as $blockName) {
$template .= sprintf('{%% block %1$s %%}{{ blocks.%1$s|raw }}{%% endblock %%}', $blockName);
}

$template .= '{% endcomponent %}';

return new RenderedComponent(self::getContainer()->get('twig')
->createTemplate($template)
->render([
'data' => $data,
'blocks' => $blocks,
])
);
}
}
30 changes: 30 additions & 0 deletions src/TwigComponent/src/Test/RenderedComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Test;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class RenderedComponent implements \Stringable
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this stringable object to wrap the rendered html so we can possibly add something like described in #818 (comment) in the future.

I wasn't sure if changing the type-hint from string -> stringable object as the return type of InteractsWithTwigComponent::renderTwigComponent() would be considered a BC break. If not, I can change to just return string.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's start with this - easier to remove this later if we end up not needing it or it's a pain for some reason than to go in the other direction.

{
/**
* @internal
*/
public function __construct(private string $html)
{
}

public function __toString(): string
{
return $this->html;
}
}
22 changes: 22 additions & 0 deletions src/TwigComponent/tests/Fixtures/Component/WithSlots.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Fixtures\Component;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
#[AsTwigComponent]
final class WithSlots
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div>
{% block content %}{% endblock %}
{% block slot1 %}{% endblock %}
{% block slot2 %}{% endblock %}
</div>
13 changes: 13 additions & 0 deletions src/TwigComponent/tests/Integration/ComponentFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA;
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB;
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentC;
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithSlots;

/**
* @author Kevin Bond <kevinbond@gmail.com>
Expand Down Expand Up @@ -170,6 +171,18 @@ public function testInputPropsStoredOnMountedComponent(): void
$this->assertSame(['propA' => 'A', 'propB' => 'B'], $mountedComponent->getInputProps());
}

/**
* @doesNotPerformAssertions
*/
public function testGetComponentWithClassName(): void
{
$factory = $this->factory();

$factory->create(WithSlots::class);
$factory->get(WithSlots::class);
$factory->metadataFor(WithSlots::class);
}

private function factory(): ComponentFactory
{
return self::getContainer()->get('ux.twig_component.component_factory');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Tests\Integration\Test;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents;
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentA;
use Symfony\UX\TwigComponent\Tests\Fixtures\Component\WithSlots;
use Symfony\UX\TwigComponent\Tests\Fixtures\Service\ServiceA;

final class InteractsWithTwigComponentsTest extends KernelTestCase
{
use InteractsWithTwigComponents;

/**
* @dataProvider componentANameProvider
*/
public function testCanMountComponent(string $name): void
{
$component = $this->mountTwigComponent($name, [
'propA' => 'prop a value',
'propB' => 'prop b value',
]);

$this->assertInstanceof(ComponentA::class, $component);
$this->assertInstanceOf(ServiceA::class, $component->getService());
$this->assertSame('prop a value', $component->propA);
$this->assertSame('prop b value', $component->getPropB());
}

/**
* @dataProvider componentANameProvider
*/
public function testCanRenderComponent(string $name): void
{
$rendered = $this->renderTwigComponent($name, [
'propA' => 'prop a value',
'propB' => 'prop b value',
]);

$this->assertStringContainsString('propA: prop a value', $rendered);
$this->assertStringContainsString('propB: prop b value', $rendered);
$this->assertStringContainsString('service: service a value', $rendered);
}

/**
* @dataProvider withSlotsNameProvider
*/
public function testCanRenderComponentWithSlots(string $name): void
{
$rendered = $this->renderTwigComponent(
name: $name,
content: '<p>some content</p>',
blocks: [
'slot1' => '<p>some slot1 content</p>',
'slot2' => $this->renderTwigComponent('component_a', [
'propA' => 'prop a value',
'propB' => 'prop b value',
]),
],
);

$this->assertStringContainsString('<p>some content</p>', $rendered);
$this->assertStringContainsString('<p>some slot1 content</p>', $rendered);
$this->assertStringContainsString('propA: prop a value', $rendered);
$this->assertStringContainsString('propB: prop b value', $rendered);
$this->assertStringContainsString('service: service a value', $rendered);
}

public static function componentANameProvider(): iterable
{
yield ['component_a'];
yield [ComponentA::class];
}

public static function withSlotsNameProvider(): iterable
{
yield ['WithSlots'];
yield [WithSlots::class];
}
}