From faa15189ba523a23269c54a2462efc39b6d78d56 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Sat, 29 Apr 2023 12:42:35 -0400 Subject: [PATCH] [Live] add test helper --- src/LiveComponent/CHANGELOG.md | 4 + src/LiveComponent/doc/index.rst | 68 ++++++++ .../src/Test/InteractsWithLiveComponents.php | 46 ++++++ .../src/Test/TestLiveComponent.php | 149 ++++++++++++++++++ .../tests/Fixtures/Component/TrackRenders.php | 32 ++++ .../components/track_renders.html.twig | 3 + .../Test/InteractsWithLiveComponentsTest.php | 112 +++++++++++++ src/TwigComponent/CHANGELOG.md | 1 + src/TwigComponent/composer.json | 2 + src/TwigComponent/doc/index.rst | 3 + .../src/Test/RenderedComponent.php | 16 ++ .../components/component_a.html.twig | 5 + .../Test/InteractsWithTwigComponentsTest.php | 1 + 13 files changed, 442 insertions(+) create mode 100644 src/LiveComponent/src/Test/InteractsWithLiveComponents.php create mode 100644 src/LiveComponent/src/Test/TestLiveComponent.php create mode 100644 src/LiveComponent/tests/Fixtures/Component/TrackRenders.php create mode 100644 src/LiveComponent/tests/Fixtures/templates/components/track_renders.html.twig create mode 100644 src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index f9b8286e48a..595ff9ff409 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.11.0 + +- Add helper for testing live components. + ## 2.9.0 - Add support for symfony/asset-mapper diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index bc7835397fc..1ce22572e7d 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -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 ------------------------------ diff --git a/src/LiveComponent/src/Test/InteractsWithLiveComponents.php b/src/LiveComponent/src/Test/InteractsWithLiveComponents.php new file mode 100644 index 00000000000..37351d0c982 --- /dev/null +++ b/src/LiveComponent/src/Test/InteractsWithLiveComponents.php @@ -0,0 +1,46 @@ + + * + * 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 + */ +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'), + ); + } +} diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php new file mode 100644 index 00000000000..212d5b962b6 --- /dev/null +++ b/src/LiveComponent/src/Test/TestLiveComponent.php @@ -0,0 +1,149 @@ + + * + * 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 + */ +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 $arguments + */ + public function call(string $action, array $arguments = []): self + { + return $this->request(['args' => $arguments], $action); + } + + /** + * @param array $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'); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Component/TrackRenders.php b/src/LiveComponent/tests/Fixtures/Component/TrackRenders.php new file mode 100644 index 00000000000..9ab5d23c067 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/TrackRenders.php @@ -0,0 +1,32 @@ + + * + * 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; + } +} diff --git a/src/LiveComponent/tests/Fixtures/templates/components/track_renders.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/track_renders.html.twig new file mode 100644 index 00000000000..94fad15bb89 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/track_renders.html.twig @@ -0,0 +1,3 @@ + + Re-Render Count: {{ reRenders }} + diff --git a/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php new file mode 100644 index 00000000000..02ba965c62f --- /dev/null +++ b/src/LiveComponent/tests/Functional/Test/InteractsWithLiveComponentsTest.php @@ -0,0 +1,112 @@ + + */ +final class InteractsWithLiveComponentsTest extends KernelTestCase +{ + use InteractsWithLiveComponents; + + public function testCanRenderInitialData(): void + { + $testComponent = $this->createLiveComponent('component2'); + + $this->assertStringContainsString('Count: 1', (string) $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + } + + public function testCanCreateWithClassString(): void + { + $testComponent = $this->createLiveComponent(Component2::class); + + $this->assertStringContainsString('Count: 1', (string) $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + } + + public function testCanCallLiveAction(): void + { + $testComponent = $this->createLiveComponent('component2'); + + $this->assertStringContainsString('Count: 1', $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + + $testComponent->call('increase'); + + $this->assertStringContainsString('Count: 2', $testComponent->render()); + $this->assertSame(2, $testComponent->component()->count); + } + + public function testCanCallLiveActionWithArguments(): void + { + $testComponent = $this->createLiveComponent('component6'); + + $this->assertStringContainsString('Arg1: not provided', $testComponent->render()); + $this->assertStringContainsString('Arg2: not provided', $testComponent->render()); + $this->assertStringContainsString('Arg3: not provided', $testComponent->render()); + $this->assertNull($testComponent->component()->arg1); + $this->assertNull($testComponent->component()->arg2); + $this->assertNull($testComponent->component()->arg3); + + $testComponent->call('inject', ['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']); + + $this->assertStringContainsString('Arg1: hello', $testComponent->render()); + $this->assertStringContainsString('Arg2: 666', $testComponent->render()); + $this->assertStringContainsString('Arg3: 33.3', $testComponent->render()); + $this->assertSame('hello', $testComponent->component()->arg1); + $this->assertSame(666, $testComponent->component()->arg2); + $this->assertSame(33.3, $testComponent->component()->arg3); + } + + public function testCanSetLiveProp(): void + { + $testComponent = $this->createLiveComponent('component_with_writable_props'); + + $this->assertStringContainsString('Count: 1', $testComponent->render()); + $this->assertSame(1, $testComponent->component()->count); + + $testComponent->set('count', 100); + + $this->assertStringContainsString('Count: 100', $testComponent->render()); + $this->assertSame(100, $testComponent->component()->count); + } + + public function testCanRefreshComponent(): void + { + $testComponent = $this->createLiveComponent('track_renders'); + + $this->assertStringContainsString('Re-Render Count: 1', $testComponent->render()); + + $testComponent->refresh(); + + $this->assertStringContainsString('Re-Render Count: 2', $testComponent->render()); + + $testComponent->refresh(); + + $this->assertStringContainsString('Re-Render Count: 3', $testComponent->render()); + } + + public function testCanAccessResponse(): void + { + $testComponent = $this->createLiveComponent('component2'); + + $response = $testComponent->call('redirect')->response(); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame('1', $response->headers->get('X-Custom-Header')); + } + + public function testCannotUpdateComponentIfNoLongerInContext(): void + { + $testComponent = $this->createLiveComponent('component2')->call('redirect'); + + $this->expectException(\LogicException::class); + + $testComponent->call('increase'); + } +} diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 9ebf28ca5ce..45c2afb4bee 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.11.0 - Support ...spread operator with html syntax (requires Twig 3.7.0 or higher) +- Add `RenderedComponent::crawler()` and `toString()` methods. ## 2.9.0 diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json index 536e259a2a3..29e303f329d 100644 --- a/src/TwigComponent/composer.json +++ b/src/TwigComponent/composer.json @@ -34,6 +34,8 @@ "twig/twig": "^2.14.7|^3.0.4" }, "require-dev": { + "symfony/css-selector": "^5.4|^6.0", + "symfony/dom-crawler": "^5.4|^6.0", "symfony/framework-bundle": "^5.4|^6.0", "symfony/phpunit-bridge": "^6.0", "symfony/stimulus-bundle": "^2.9.1", diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst index 66dd8e2fb2d..2cd8c8137e9 100644 --- a/src/TwigComponent/doc/index.rst +++ b/src/TwigComponent/doc/index.rst @@ -1078,6 +1078,9 @@ You can test how your component is mounted and rendered using the ); $this->assertStringContainsString('bar', $rendered); + + // use the crawler + $this->assertCount(5, $rendered->crawler('ul li')); } public function testEmbeddedComponentRenders(): void diff --git a/src/TwigComponent/src/Test/RenderedComponent.php b/src/TwigComponent/src/Test/RenderedComponent.php index 54cc27f79df..ac3469cb17d 100644 --- a/src/TwigComponent/src/Test/RenderedComponent.php +++ b/src/TwigComponent/src/Test/RenderedComponent.php @@ -11,6 +11,8 @@ namespace Symfony\UX\TwigComponent\Test; +use Symfony\Component\DomCrawler\Crawler; + /** * @author Kevin Bond */ @@ -23,6 +25,20 @@ public function __construct(private string $html) { } + public function crawler(): Crawler + { + if (!class_exists(Crawler::class)) { + throw new \LogicException(sprintf('"symfony/dom-crawler" is required to use "%s()" (install with "composer require symfony/dom-crawler").', __METHOD__)); + } + + return new Crawler($this->html); + } + + public function toString(): string + { + return $this->html; + } + public function __toString(): string { return $this->html; diff --git a/src/TwigComponent/tests/Fixtures/templates/components/component_a.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/component_a.html.twig index 09b16225697..e5a34b0b7b9 100644 --- a/src/TwigComponent/tests/Fixtures/templates/components/component_a.html.twig +++ b/src/TwigComponent/tests/Fixtures/templates/components/component_a.html.twig @@ -1,3 +1,8 @@ propA: {{ propA }} propB: {{ this.propB }} service: {{ this.service.value }} + +
    +
  • foo
  • +
  • bar
  • +
diff --git a/src/TwigComponent/tests/Integration/Test/InteractsWithTwigComponentsTest.php b/src/TwigComponent/tests/Integration/Test/InteractsWithTwigComponentsTest.php index 6ca156e0b15..7e2b5d077e8 100644 --- a/src/TwigComponent/tests/Integration/Test/InteractsWithTwigComponentsTest.php +++ b/src/TwigComponent/tests/Integration/Test/InteractsWithTwigComponentsTest.php @@ -50,6 +50,7 @@ public function testCanRenderComponent(string $name): void $this->assertStringContainsString('propA: prop a value', $rendered); $this->assertStringContainsString('propB: prop b value', $rendered); $this->assertStringContainsString('service: service a value', $rendered); + $this->assertCount(2, $rendered->crawler()->filter('ul li')); } /**