Skip to content

Commit

Permalink
feature #821 [Twig] add test helper (kbond)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[Twig] add test helper

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Tickets       | Fix #818
| License       | MIT

_This works with Live components out of the box (not with slots though as they aren't supported)._

```php
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);
    }
}
```

Commits
-------

0b27ea9 [Twig] add test helper
  • Loading branch information
weaverryan committed Jun 26, 2023
2 parents 6ee3f52 + 0b27ea9 commit 21d16ea
Show file tree
Hide file tree
Showing 10 changed files with 305 additions and 5 deletions.
3 changes: 2 additions & 1 deletion src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,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 @@ -922,6 +922,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 <[email protected]>
*
* 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 <[email protected]>
*/
trait InteractsWithTwigComponents
{
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,
])
);
}

$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 <[email protected]>
*
* 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 <[email protected]>
*/
final class RenderedComponent implements \Stringable
{
/**
* @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 <[email protected]>
*
* 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 <[email protected]>
*/
#[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 <[email protected]>
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 <[email protected]>
*
* 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];
}
}

0 comments on commit 21d16ea

Please sign in to comment.