ux icon indicating copy to clipboard operation
ux copied to clipboard

[LiveComponent] Mock a LiveComponent dependencies

Open GrinWay opened this issue 9 months ago • 9 comments

Is there any way to mock a dependency container when form is submitted

I was not able to get mocked http_client when form was submitted

Maybe it's because Live Component actually makes another request to render the component...

Well... Question is: Is it possible to get mocked services where twig component renders (save method for instance, for saving entities when form submitted)

Because when I try to mock it I get a real http_client I mean: MY TEST USES MOCKED VERSION OF http_client but when form is submitted during the same test: I GET A REAL htt_client ON FORM SUBMITTION CODE SIDE

GrinWay avatar Mar 24 '25 11:03 GrinWay

Hi, just to be sure, you want to mock services used by your components, like your database, or to mock the HttpClient used by createLiveComponent()?

Kocal avatar Mar 27 '25 09:03 Kocal

All I want is to get mocked services (http_client) on the twig live component side:

  1. I'm inside the test
  2. Tests have mocked dependencies
  3. I create live tiwg component with createLiveComponent
  4. Send the form (5) And exactly here when form is submitted (save method for instance) I want to have the same not rebooted container

So is it possible?

Because in my Live Twig Component when the save method executes an http_client request and I constanly get a real http_client result (actually it's a telegram message) but it would be better during that tets I actully wouldn't get a real message to my test telegram bot

GrinWay avatar Mar 28 '25 21:03 GrinWay

Actually you understood me correctly

I'm looking for a way to get an origin service container for both:

  1. actual tests
  2. and live components' code they use

GrinWay avatar Mar 28 '25 21:03 GrinWay

Well, after a long research I still didn't get mocked container for a Twig Live Component code

Problem: Live Component uses not a test container but a real (I guess a new created symfony container)

What a pity

Even when I use Panther E2E tests, make a request to the page with live components and interact with live form I still deal with a rebooted container and all of http client requests make a REAL request

It seems to me that I had to mark this issue with a bug badge

GrinWay avatar Mar 29 '25 13:03 GrinWay

Got the same issue if I understand correct.

My live component injects a service into the constructor like this:

public function __construct(
    private readonly EntityManagerInterface $entityManager,
    private readonly ProcessService $processService
) {
}

Inside my KernelTestCase, I manually place the mock inside the container:

$processService = $this->createMock(ProcessService::class);
static::getContainer()->set(ProcessService::class, $processService);

Using live component test helpers, I invoke the call() function:

$selectForm = $this->createLiveComponent(
    name: SelectProcessForm::class,
    data: [
        'processHolder' => $holder,
    ]
);
$selectForm->actingAs($user);
$selectForm->call('loadOptions');

I placed a breakpoint inside my component constructor and did some debugging. After invoking call(), my constructor gets invoked three times. The first two times, my mock gets injected. But the third time uses the real service. Unfortunately, this is the instance that calls my live action.

weTobias avatar Aug 08 '25 19:08 weTobias

I've run into this today. In my case I use a searchEngine that is called from my component. I wanted to swap out the service with a mock one, but the original gets callsd.


public function testSearch(): void
{
        $searchMock = $this->createMock(SearchManagerInterface::class);
        $searchMock->expects(self::once())
            ->method('search')
            ->with($query)
            ->willReturn([new SearchResultDTO(/*data*/)]);
        static::getContainer()->set(SearchManagerInterface::class, $searchMock);

        $smi = static::getContainer()->get(SearchManagerInterface::class);
        dump($smi::class); // MockObject_SearchManagerInterface_a7173dfb

        $component = $this->createLiveComponent(SearchComponent::class);
        $crawler = $component->set('query', $query)->render();

      // Expectation failed for method name is "search" when invoked 1 time.
     // Method was expected to be called 1 time, actually called 0 times.
 }

#[AsLiveComponent]
final class SearchComponent
{
    use DefaultActionTrait;

    #[LiveProp(writable: true)]
    public string $query = '';

    public function __construct(
        private readonly SearchManagerInterface $searchManager,
        private readonly EventRepository $eventRepository
    ) {
    }
...
}

// edit, fixed my examples

mmarton avatar Oct 17 '25 19:10 mmarton

ping @Kocal feedback is already provided back in march

mmarton avatar Oct 18 '25 12:10 mmarton

Hi, sorry for the long reply, I was busy with something else :)

I tried some investigation this morning, with the following live component:

<?php

namespace App\Twig\Components;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\TwigComponent\Attribute\PreMount;

#[AsLiveComponent]
final class LiveComponentWithDependency
{
    use DefaultActionTrait;

    public function __construct(
        private HttpClientInterface $httpClient
    ) {
    }

    #[PreMount]
    public function preMount(array $data): array
    {
        $this->doRequest();

        return $data;
    }

    #[LiveAction]
    public function request(): void
    {
        $this->doRequest();
    }

    private function doRequest(): void
    {
        // dump($this->httpClient);
        $r = $this->httpClient->request('GET', 'https://symfony.com');
        dump($r->getStatusCode());
    }
}

and test:

<?php

declare(strict_types=1);

namespace App\Tests\Twig\Components;

use App\Twig\Components\LiveComponentWithDependency;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;

final class LiveComponentWithDependencyTest extends KernelTestCase
{
    use InteractsWithLiveComponents;

    public function testComponent(): void
    {
        $container = static::getContainer();
        $container->set('http_client', new MockHttpClient(new MockResponse('<empty>', ['http_code' => 400])));

        $component = $this->createLiveComponent(LiveComponentWithDependency::class);
        $component->render();

        $component->call('request');
    }
}
  • The first $component->render() correctly use the MockHttpClient and display status code 400
  • But calling $component->call('request') re-use the initial http_client instance (which is not the MockHttpClient instance) and display status code 200

I'm currently digging, but I'm not sure what's really happening. Any help would be appreciated, cc @kbond 🙏🏻

Kocal avatar Nov 02 '25 21:11 Kocal

Well... you should not change the http client as per default the test http client is used to do the request (if you do not need http request, you also can ... unit test your LiveComponent. It works often better than expected)

For the internals, the trait is where most of the dark magic occurs..

/src/Test/InteractsWithLiveComponents.php#L31-L47

        /** @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,
            $client ?? 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'),
        );

and

/src/Test/TestLiveComponent.php#L179-L216

    private function client(): KernelBrowser
    {
        if ($this->performedInitialRequest) {
            return $this->client;
        }


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


        if ('POST' === strtoupper($this->metadata->get('method'))) {
            $this->client->request(
                'POST',
                $this->router->generate($this->metadata->get('route'), array_filter([
                    '_live_component' => $this->metadata->getName(),
                    '_locale' => $this->locale,
                ], static fn (mixed $v): bool => null !== $v)),
                [
                    'data' => json_encode(['props' => $props->getProps()], flags: \JSON_THROW_ON_ERROR),
                ],
            );
        } else {
            $this->client->request('GET', $this->router->generate(
                $this->metadata->get('route'),
                array_filter([
                    '_live_component' => $this->metadata->getName(),
                    '_locale' => $this->locale,
                    'props' => json_encode($props->getProps(), flags: \JSON_THROW_ON_ERROR),
                ], static fn (mixed $v): bool => null !== $v),
            ));
        }


        $this->performedInitialRequest = true;


        return $this->client;

Side note: using WebTestCase would also create problems as the assertions would produce weird results, and the whole suite is not suited to simulate ajax calls / DOM changes between "main requests".

I would either use env-dependant service definitions, or run smaller integration tests (with no HTTP calls / kernel reboot).

smnandre avatar Nov 03 '25 04:11 smnandre