Skip to content

[Live] add test helper #823

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
Aug 17, 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
4 changes: 4 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 2.11.0

- Add helper for testing live components.

## 2.9.0

- Add support for symfony/asset-mapper
Expand Down
68 changes: 68 additions & 0 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3033,6 +3033,74 @@ Then specify this new route on your component:
use DefaultActionTrait;
}

Test Helper
-----------

.. versionadded:: 2.11

The test helper was added in LiveComponents 2.11.

For testing, you can use the ``InteractsWithLiveComponents`` trait which
uses Symfony's test client to render and make requests to your components::

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;

class MyComponentTest extends KernelTestCase
{
use InteractsWithLiveComponents;

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

// render the component html
$this->assertStringContainsString('Count: 0', $testComponent->render());

// call live actions
$testComponent
->call('increase')
->call('increase', ['amount' => 2]) // call a live action with arguments
;

$this->assertStringContainsString('Count: 3', $testComponent->render());

// emit live events
$testComponent
->emit('increaseEvent')
->emit('increaseEvent', ['amount' => 2]) // emit a live event with arguments
;

// set live props
$testComponent
->set('count', 99)
;

$this->assertStringContainsString('Count: 99', $testComponent->render());

// refresh the component
$testComponent->refresh();

// access the component object (in it's current state)
$component = $testComponent->component(); // MyComponent

$this->assertSame(99, $component->count);

// test a live action that redirects
$response = $testComponent->call('redirect')->response(); // Symfony\Component\HttpFoundation\Response

$this->assertSame(302, $response->getStatusCode());
}
}

.. note::

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

Backward Compatibility promise
------------------------------

Expand Down
46 changes: 46 additions & 0 deletions src/LiveComponent/src/Test/InteractsWithLiveComponents.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?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\LiveComponent\Test;

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

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
trait InteractsWithLiveComponents
{
protected function createLiveComponent(string $name, array $data = []): TestLiveComponent
{
if (!$this instanceof KernelTestCase) {
throw new \LogicException(sprintf('The "%s" trait can only be used on "%s" classes.', __TRAIT__, KernelTestCase::class));
}

/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');
$metadata = $factory->metadataFor($name);

if (!$metadata->get('live')) {
throw new \LogicException(sprintf('The "%s" component is not a live component.', $name));
}

return new TestLiveComponent(
$metadata,
$data,
$factory,
self::getContainer()->get('test.client'),
self::getContainer()->get('ux.live_component.component_hydrator'),
self::getContainer()->get('ux.live_component.metadata_factory'),
self::getContainer()->get('router'),
);
}
}
149 changes: 149 additions & 0 deletions src/LiveComponent/src/Test/TestLiveComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?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\LiveComponent\Test;

use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\MountedComponent;
use Symfony\UX\TwigComponent\Test\RenderedComponent;

/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class TestLiveComponent
{
/**
* @internal
*/
public function __construct(
private ComponentMetadata $metadata,
array $data,
private ComponentFactory $factory,
private KernelBrowser $client,
private LiveComponentHydrator $hydrator,
private LiveComponentMetadataFactory $metadataFactory,
private UrlGeneratorInterface $router,
) {
$this->client->catchExceptions(false);

$mounted = $this->factory->create($this->metadata->getName(), $data);
$props = $this->hydrator->dehydrate(
$mounted->getComponent(),
$mounted->getAttributes(),
$this->metadataFactory->getMetadata($mounted->getName())
);

$this->client->request('GET', $this->router->generate(
$this->metadata->get('route'),
[
'_live_component' => $this->metadata->getName(),
'props' => json_encode($props->getProps(), flags: \JSON_THROW_ON_ERROR),
]
));
}

public function render(): RenderedComponent
{
return new RenderedComponent($this->response()->getContent());
}

public function component(): object
{
$component = $this->factory->get($this->metadata->getName());
$componentAttributes = $this->hydrator->hydrate(
$component,
$this->props(),
[],
$this->metadataFactory->getMetadata($this->metadata->getName()),
);

return (new MountedComponent($this->metadata->getName(), $component, $componentAttributes))->getComponent();
}

/**
* @param array<string,mixed> $arguments
*/
public function call(string $action, array $arguments = []): self
{
return $this->request(['args' => $arguments], $action);
}

/**
* @param array<string,mixed> $arguments
*/
public function emit(string $event, array $arguments = []): self
{
return $this->call($event, $arguments);
}

public function set(string $prop, mixed $value): self
{
return $this->request(['updated' => [$prop => $value]]);
}

public function refresh(): self
{
return $this->request();
}

public function response(): Response
{
return $this->client->getResponse();
}

private function request(array $content = [], string $action = null): self
{
$csrfToken = $this->csrfToken();

$this->client->request(
'POST',
$this->router->generate(
$this->metadata->get('route'),
array_filter([
'_live_component' => $this->metadata->getName(),
'_live_action' => $action,
])
),
parameters: ['data' => json_encode(array_merge($content, ['props' => $this->props()]))],
server: $csrfToken ? ['HTTP_X_CSRF_TOKEN' => $csrfToken] : [],
);

return $this;
}

private function props(): array
{
$crawler = $this->client->getCrawler();

if (!\count($node = $crawler->filter('[data-live-props-value]'))) {
throw new \LogicException('A live component action has redirected and you can no longer access the component.');
}

return json_decode($node->attr('data-live-props-value'), true, flags: \JSON_THROW_ON_ERROR);
}

private function csrfToken(): ?string
{
$crawler = $this->client->getCrawler();

if (!\count($node = $crawler->filter('[data-live-csrf-value]'))) {
return null;
}

return $node->attr('data-live-csrf-value');
}
}
32 changes: 32 additions & 0 deletions src/LiveComponent/tests/Fixtures/Component/TrackRenders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?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\LiveComponent\Tests\Fixtures\Component;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Attribute\PreReRender;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('track_renders')]
final class TrackRenders
{
use DefaultActionTrait;

#[LiveProp]
public int $reRenders = 0;

#[PreReRender]
public function preReRender(): void
{
++$this->reRenders;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div{{ attributes }}>
Re-Render Count: {{ reRenders }}
</div>
Loading