Skip to content

Commit

Permalink
[Live] add test helper
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond authored and weaverryan committed Aug 17, 2023
1 parent b2c00e6 commit faa1518
Show file tree
Hide file tree
Showing 13 changed files with 442 additions and 0 deletions.
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 <[email protected]>
*
* 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 <[email protected]>
*/
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 <[email protected]>
*
* 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 <[email protected]>
*/
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 <[email protected]>
*
* 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

0 comments on commit faa1518

Please sign in to comment.