Skip to content

Test Components

Drive a component against a fake terminal with TestScreen — send it input and assert on what it renders.

Overview

TestScreen renders into an in-memory buffer instead of a real terminal, so tests run under Pest or PHPUnit like any other — no TTY, no subprocess. You mount a component at a chosen cell size, send it keys, clicks and elapsed time, then assert on the rendered frame, the emitted events, or a returned value.

Mount a component

TestScreen::mount() takes the root component and a cell size. A component with its own constructor is mounted with new; anything a parent would supply needs a small host.

Mount and assert

Mount at a chosen size, then assert on the rendered frame:

php
use Phui\Testing\TestScreen;

it('greets the user', function () {
    TestScreen::mount(new Greeter('Sam'), 40, 10)
        ->assertSee('Hello, Sam');
});

assertSee() and assertDontSee() match against the plain text of the frame. For anything finer, take a Capture with capture() and read it as text or cell by cell.

With props and models

Props and models are supplied by a parent, and emitted events are handled by one — so to test those, give the component a small host. An anonymous component is enough:

php
$host = new class extends Component {
    public function build(): Node
    {
        return Select::mount()->props(
            placeholder: 'Pick a fruit',
            items: ['Apple', 'Banana', 'Cherry'],
        );
    }
};

TestScreen::mount($host, 40, 10)->assertSee('Pick a fruit');

Drive input

Every input method returns the screen, so calls chain. Drive the component the way a user would, asserting between steps.

Send keys and clicks

php
use Phui\Events\Keyboard\Keypress;

$screen = TestScreen::mount(new Counter, 20, 3);

$screen->press(Keypress::ARROW_UP)->assertSee('1');
$screen->type('hello');
$screen->click(4, 2);

press() sends one keypress, type() sends a string a character at a time, and click() and scroll() send mouse events at a cell.

Advance time

Timers and animations do not fire on their own in a test — move the fake clock forward with advance(). A component that increments on a one-second interval:

php
$screen = TestScreen::mount(new Ticker, 30, 3);

$screen->assertSee('Ticks: 0');
$screen->advance(1000);
$screen->assertSee('Ticks: 1');

Wait for async work

When a handler awaits, the work resolves between frames. settle() ticks the screen until nothing is pending, so the resolved state is on screen before you assert:

php
$screen = TestScreen::mount(new UserList, 40, 20);

$screen->press(Keypress::r)->settle();
$screen->assertSee('Ada Lovelace');

If the work resolves on an external event loop, pass a closure to settle() to drive that loop between ticks.

Assert on output

Beyond assertSee(), catch what a component emits, snapshot its frame, or check the value it completes with.

Emitted events

Catch what the component emits from its host, reading the payload off the handler's EmitEvent:

php
use Phui\Events\EmitEvent;

$picked = new stdClass;

$host = new class($picked) extends Component {
    public function __construct(private stdClass $picked) {}

    public function build(): Node
    {
        return Select::mount()
            ->props(items: ['Apple', 'Banana'])
            ->on('change', fn (EmitEvent $e) => $this->picked->value = $e->data);
    }
};

$screen = TestScreen::mount($host, 40, 10);
$screen->press(Keypress::ENTER)
    ->press(Keypress::ARROW_DOWN)
    ->press(Keypress::ENTER);

expect($picked->value)->toBe('Banana');

Snapshots

Phui produces the frame; your test framework stores and compares it. There is no snapshot storage in Phui itself — hand a Capture to your framework's snapshot assertion:

php
it('renders the dashboard', function () {
    $screen = TestScreen::mount(new Dashboard, 60, 16)->press(Keypress::j);

    expect($screen->capture()->toAnsi())->toMatchSnapshot();
});

Snapshot toText() when only the layout matters and styling churn should not fail the test; snapshot toAnsi() when colours and decorations are part of the contract. Either way the comparison is over the terminal cell grid, not rendered pixels, so snapshots are deterministic across machines and CI.

Completion

A component that ends the run with complete() exposes the result:

php
$screen = TestScreen::mount(new Wizard, 40, 20);

$screen->press(Keypress::ENTER);

expect($screen->completed())->toBeTrue();
expect($screen->result())->toBe('confirmed');

Released under the MIT License.