enderandpeter@yahoo.com's avatar

Livewire component test non-initialized model property or infinite loop

I have a component class that looks like this:

<?php

namespace App\Http\Livewire\Dashboard;

use App\Models\Team;
use App\Models\User;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Livewire\Component;

class LocationTracker extends Component
{
    public Team $team;

    public bool $tracking = false;

    public function mount(): void{
        $this->tracking = $this->getTrackingProperty();
    }

    public function track(): void{
        if(!auth()->user()->can('lockLocation', $this->team)){
            $this->tracking = false;
            return;
        }

        $user = null;

        if(!$this->team->trackedUser){
            $user = auth()->user();
        }

        $this->team->trackedUser()->associate($user);
        $this->team->save();

        $this->tracking = !!$this->team->trackedUser;
    }

    /**
     * @return bool The value of the $tracking property is a boolean
     *              indicating whether or not this team's location is being tracked
     */
    public function getTrackingProperty(): bool{
        return $this->team->trackedUser instanceof User;
    }

    public function render(): Factory|View|Application
    {
        return view('livewire.dashboard.location-tracker');
    }
}

The corresponding Livewire template looks like this:

<div wire:key="team-id-{{ $team->id }}" class="location-tracker-toggle-container">
    <label for="location-tracker-toggle"></label>
    <input
        id="location-tracker-toggle"
        type="checkbox"
        wire:model="tracking"
        wire:change.prevent="track"
        wire:loading.attr="disabled"
    />
</div>

This template is in a Blade component that looks like this:

<div class="team-container">
    <div {{ $isCurrentTeam() ? 'id=current-team' : '' }}
        {{ $attributes->merge(['class' => $getCssClasses ]) }}
         data-team-id="{{ $team?->id }}"
    >
        <h1 class="team-name">{{ $team?->name }}</h1>
        <livewire:dashboard.location-tracker :team="$team"/>
    </div>
</div>

Basically, LocationTracker is expecting a Team property so that the user can track their location on behalf of the Team. There is a one-to-one relationship setup where the teams table has a tracked_user_id column that refers to users.id.

All I want is to write a simple test:

Livewire::actingAs($admin)
            ->test(LocationTracker::class)
            ->set('team', $admin->currentTeam)
            ->call('track')
            ->assertSet('tracking', true);

so I can confirm that a team admin can track their location. The $team property needs to be set, but when I do it like this, I get an error: Typed property App\Http\Livewire\Dashboard\LocationTracker::$team must not be accessed before initialization, probably because of the mount method. When I set the $team property to a nullable Team and change every method call on a team to use the nullable property accessor so that it is okay with a null value, I then get an odd error about PHP, or rather Xdebug, detecting an infinite loop after "a stack depth of '256' frames".

I also get the infinite loop error if I try to set team with an array as the second parameter to test. Why can't this test simply set team to the model object property of the LocationTracker component?:

Xdebug has detected a possible infinite loop, and aborted your script with a stack depth of '256' frames (View: /var/www/html/vendor/livewire/livewire/src/views/mount-component.blade.php)

  at vendor/laravel/framework/src/Illuminate/Collections/HigherOrderCollectionProxy.php:59
     55▕      * @return mixed
     56▕      */
     57▕     public function __call($method, $parameters)
     58▕     {
  ➜  59▕         return $this->collection->{$this->method}(function ($value) use ($method, $parameters) {
     60▕             return $value->{$method}(...$parameters);
     61▕         });
     62▕     }
     63▕ }

      +3 vendor frames 
  4   [internal]:0
      Illuminate\Support\HigherOrderCollectionProxy::Illuminate\Support\{closure}(Object(App\Models\Team))

      +6 vendor frames 
  11  [internal]:0
      Illuminate\Support\HigherOrderCollectionProxy::Illuminate\Support\{closure}(Object(App\Models\User))

In the actual web app, this works perfectly fine. Am I missing something or does this component test feature really not work as advertised?

0 likes
1 reply
enderandpeter@yahoo.com's avatar
Level 2

I finally found the issue, thanks to Xdebug.

It was in code that I had not shared, of course. In my TeamFactory, I was using User->switchTeams, which calls the save method on a model. This is what is triggering the infinite loop, calling the save on a model in a factory class. I replaced the switchTeams call with the core of that method, which force-fills current_team_id. No more infinite loop, and the test appears to work.

Please or to participate in this conversation.