Start Here
Testing
Wire components are plain PHP objects that render to HTML, so they are straightforward to test. There are three useful levels:
On this page
Wire components are plain PHP objects that render to HTML, so they are straightforward to test. There are three useful levels:
| Level | Use for | Needs Livewire? |
|---|---|---|
| Standalone | Validation rules, save logic, configuration | No |
| Livewire | State binding, actions, errors, the full request cycle | Yes |
| Unit | A single field/column's API and rendered output | No |
The packages are tested with Pest. The examples below use Pest syntax, but everything works the same in plain PHPUnit.
Testing Forms Standalone
A Form works without Livewire, which makes validation and save logic the
fastest thing to test. validate() returns the validated data or throws
Illuminate\Validation\ValidationException.
use Illuminate\Validation\ValidationException;use NyonCode\WireForms\Components\TextInput;use NyonCode\WireForms\Forms\Form; it('validates required fields', function () { $data = Form::make() ->schema([ TextInput::make('name')->required(), TextInput::make('email')->rules(['email'])->required(), ]) ->state(['name' => 'John', 'email' => 'john@example.com']) ->validate(); expect($data)->toBe(['name' => 'John', 'email' => 'john@example.com']);}); it('rejects a missing required field', function () { $form = Form::make() ->schema([TextInput::make('name')->required()]) ->state(['name' => '']); expect(fn () => $form->validate())->toThrow(ValidationException::class);});
Assert on collected rules and state without running validation:
$form = Form::make() ->statePath('data') ->schema([TextInput::make('name')->required()->maxLength(255)]); expect($form->getValidationRules())->toHaveKey('data.name');
Test the save path against the database by giving the form a model:
use App\Models\User; it('creates a user on save', function () { Form::make() ->model(User::class) ->schema([ TextInput::make('name')->required(), TextInput::make('email')->email()->required(), ]) ->state(['name' => 'Jane', 'email' => 'jane@example.com']) ->save(); expect(User::where('email', 'jane@example.com')->exists())->toBeTrue();});
Use model($instance) instead of model(User::class) to test edit mode
(save() calls update()), and isCreating() / isEditing() to assert the
mode.
Testing Forms in Livewire
To exercise state binding, the save action, and validation errors as a user
would hit them, mount the host component with Livewire::test(). State lives
under the form's statePath, so you set and assert nested keys like
data.name.
use Livewire\Livewire; it('shows a validation error for a missing name', function () { Livewire::test(CreateUser::class) ->set('data.email', 'jane@example.com') ->call('save') ->assertHasErrors('data.name');}); it('saves when the form is valid', function () { Livewire::test(CreateUser::class) ->set('data.name', 'Jane') ->set('data.email', 'jane@example.com') ->call('save') ->assertHasNoErrors() ->assertOk(); expect(User::where('email', 'jane@example.com')->exists())->toBeTrue();});
The host component is just a Livewire component using WithForms:
use Livewire\Component;use NyonCode\WireForms\Forms\Form;use NyonCode\WireForms\Forms\WithForms; class CreateUser extends Component{ use WithForms; public ?array $data = []; public function form(Form $form): Form { return $form->statePath('data')->model(User::class)->schema([ TextInput::make('name')->required(), TextInput::make('email')->email()->required(), ]); } public function save(): void { $this->form->save(); } public function render(): string { return '<div>{{ $this->form }}</div>'; }}
Repeater and other field actions are Livewire calls, so test them with
->call():
Livewire::test(CreateUser::class) ->call('addRepeaterItem', 'data.contacts') ->assertCount('data.contacts', 1);
Testing Tables in Livewire
A table is a Livewire component using WithTable. Its UI state (search, filters,
sort, pagination) lives on the synthesized tableState property, so you drive it
by setting nested paths like tableState.search. Row actions run through
executeTableAction($recordKey, $actionName).
use Livewire\Livewire; it('lists and searches records', function () { Invoice::factory()->create(['number' => 'INV-100']); Invoice::factory()->create(['number' => 'INV-200']); Livewire::test(InvoicesTable::class) ->assertOk() ->assertSee('INV-100') ->set('tableState.search', 'INV-100') ->assertSee('INV-100') ->assertDontSee('INV-200');}); it('runs a row action', function () { $invoice = Invoice::factory()->create(['status' => 'draft']); Livewire::test(InvoicesTable::class) ->call('executeTableAction', (string) $invoice->getKey(), 'publish'); expect($invoice->fresh()->status)->toBe('published');});
Bulk actions use executeBulkAction($actionName) after selecting rows, and
actions that open a modal go through openActionModal() then
submitActionModal(). If an assertion does not match, dump the component with
->dump() to inspect the live tableState.
Unit-Testing a Custom Field
When you write a custom field, test its API and rendered output directly — no Livewire required.
use App\Forms\Components\MoneyInput; it('exposes its configuration and renders the currency', function () { $field = MoneyInput::make('price')->currency('EUR')->decimals(2); expect($field->getCurrency())->toBe('EUR') ->and($field->getStateType())->toBe('int') ->and((string) $field->toHtml())->toContain('EUR');});
The same approach works for custom columns, filters, and actions: build the
object, call its setters, and assert on its getters or toHtml().
Testing Plugins
Instantiate PluginManager directly to test registration, boot, and hooks. See
Core Plugins → Testing Plugins for the full
pattern.
use NyonCode\WireCore\Core\Plugin\PluginManager; it('adds updated_by before save', function () { $manager = new PluginManager(); $manager->register(new FormAuditPlugin()); $payload = $manager->runHook('form.saving', ['data' => ['name' => 'Jane']]); expect($payload['data'])->toHaveKey('updated_by');});
Running the Suite
composer test # everythingcomposer test:corecomposer test:formscomposer test:tablecomposer test:sortable # Cross-package runtime, state, macros, pluginsvendor/bin/pest --configuration phpunit.xml --testsuite "Integration"
Run the owning package first when you change one package, then the downstream package(s), and the Integration suite when state, rendering, macros, or plugin wiring changed.
See Also
- Extending Forms — building the fields you are testing
- Save Lifecycle — the hooks
save()runs through - Core Plugins — hook and plugin testing