Laravel Testing with Pest, Part 3: Browser Testing

8 min readUpdated on

Laravel Testing with Pest — Part 1 · Part 2 · Part 3 · Part 4 · Part 5

A feature test proves that a controller redirects and that a record reaches the database. It cannot prove that a disabled button becomes enabled, an Inertia visit renders its success state, or an unhandled JavaScript error stopped the journey halfway through. Browser tests cover that last boundary: the page a person actually sees in a real browser.

That does not make them the default test for every endpoint. They are slower, need a browser runtime, and fail with less focused diagnostics than a feature test. Use them for a small set of expensive journeys: signing in, publishing a post, paying for an order, or completing a form whose JavaScript matters. Keep validation matrices, policies, and domain rules in fast unit and HTTP tests.

This article builds a browser test for creating a support request. The discipline is more important than any individual API: deterministic state, selectors a user can understand, observable waits, and assertions that expose JavaScript failures instead of merely finding a page.

Start with a journey, not a screen inventory

Suppose an authenticated customer submits a support request. The form has a subject, a category, and a message. JavaScript handles submission and the user receives visible confirmation. The promise is not “this form has three fields”. It is: a customer can send a valid request, sees confirmation, and encounters no browser error.

The route and controller still deserve conventional feature tests. A browser test is the thin final proof that backend and frontend agree.

php
<?php

declare(strict_types=1);

use App\Http\Controllers\SupportRequestController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function (): void {
    Route::get('/support/requests/create', [SupportRequestController::class, 'create'])
        ->name('support-requests.create');
    Route::post('/support/requests', [SupportRequestController::class, 'store'])
        ->name('support-requests.store');
});

Do not move every rendering assertion into a real browser. Test invalid request cases and controller responses in tests/Feature. Keep this spec for the contract crossing JavaScript, navigation and rendered UI.

Create all state explicitly

An intermittent browser test is usually a state problem wearing a timing mask. Reset the database, create the actor, and create reference data rather than assuming a local seed exists. RefreshDatabase isolates each example; factories make its setup obvious.

php
<?php

declare(strict_types=1);

use App\Models\SupportCategory;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    Http::preventStrayRequests();

    $this->customer = User::factory()->create([
        'email' => '[email protected]',
    ]);

    $this->billingCategory = SupportCategory::factory()->create([
        'name' => 'Billing',
        'is_active' => true,
    ]);
});

Http::preventStrayRequests() pays off even when this page has no remote call today. A later analytics beacon, address lookup, or AI helper should fail the test immediately, not contact the internet from CI. If the flow intentionally calls an integration, fake its exact response in the test. The question is “did our product work?”, not “was a vendor available today?”

Avoid shared accounts, an existing SQLite file, and SupportCategory::first(). They hide preconditions. Predictable dates, UUIDs and queued jobs need the same treatment: freeze or fake them at the layer that owns them.

Select controls as users do

An accessible name is usually the most durable selector. A screen-reader user needs it too, so the test reinforces a real requirement. Billing and a button named Send request describe the product. .grid > div:nth-child(2) button describes a temporary implementation.

Here is the entire happy-path test. It belongs in tests/Browser; visit only after the user, category and HTTP policy exist.

php
<?php

declare(strict_types=1);

use App\Models\SupportRequest;

it('lets a customer submit a support request', function (): void {
    $this->actingAs($this->customer);

    $page = visit(route('support-requests.create'));

    $page->assertSee('New support request')
        ->assertNoJavaScriptErrors()
        ->fill('Subject', 'Invoice contains the wrong company name')
        ->select('Category', $this->billingCategory->name)
        ->fill('Message', 'Please correct the name before the payment deadline.')
        ->click('Send request')
        ->waitForText('Your request has been sent')
        ->assertNoJavaScriptErrors();

    expect(SupportRequest::query()->where([
        'user_id' => $this->customer->id,
        'subject' => 'Invoice contains the wrong company name',
        'support_category_id' => $this->billingCategory->id,
    ])->exists())->toBeTrue();
});

The assertion after navigation is deliberate. An exception on initial render is not the only failure mode: a broken toast or hydration step can happen after the POST. Checking the console at both ends narrows the diagnostic window.

When a control has no useful label, repair the UI instead of using a fragile selector. This React field gives both users and tests a stable contract:

tsx
<label htmlFor="support-subject">Subject</label>
<input
    id="support-subject"
    name="subject"
    required
    value={data.subject}
    onChange={(event) => setData('subject', event.target.value)}
/>

<button type="submit" disabled={processing}>
    {processing ? 'Sending request…' : 'Send request'}
</button>

Use data-testid only where no meaningful accessible name exists, such as a decorative map canvas. It is a named escape hatch, not permission to tag every div.

Wait for an outcome the customer can see

sleep(2) is a bet that a shared CI runner is fast enough. It wastes time on a fast run and flakes on a slow one. Wait instead for an observable result: a heading, toast, disappearing loader, or enabled control. Those states are also useful UX commitments.

php
<?php

declare(strict_types=1);

it('shows completion after importing a CSV', function (): void {
    $this->actingAs($this->customer);

    $page = visit('/imports/create');

    $page->attach('CSV file', base_path('tests/Fixtures/customers.csv'))
        ->click('Import customers')
        ->waitForText('Import complete')
        ->assertNoJavaScriptErrors();
});

If a queue owns completion, configure this environment for a synchronous queue or fake the service boundary. Do not make a UI test depend on a separate worker happening to consume a job. A true multi-process deployment test belongs in a dedicated staging smoke suite with its own timeout and diagnosis.

Cover one recoverable failure

The happy path catches integration gaps; one representative invalid path proves the interface remains understandable. It does not replace the feature-test table covering every validation rule.

php
<?php

declare(strict_types=1);

use App\Models\SupportRequest;

it('keeps the form visible when the message is missing', function (): void {
    $this->actingAs($this->customer);

    $page = visit(route('support-requests.create'));

    $page->fill('Subject', 'Question about my invoice')
        ->select('Category', $this->billingCategory->name)
        ->click('Send request')
        ->waitForText('The message field is required.')
        ->assertSee('New support request')
        ->assertNoJavaScriptErrors();

    expect(SupportRequest::query()->count())->toBe(0);
});

Do not assert the whole visual layout or every translated phrase. Important journeys would then fail for unrelated copy or spacing edits. Assert the state a customer needs: error shown, form retained, no record created.

Support browsers in CI deliberately

Browser tests require the app server, a supported browser executable, and its operating-system dependencies. Pin PHP, Node and browser versions in CI, install the browser in the job image, and isolate the application URL and database per run. Start serially: shared ports, download directories and databases produce misleading failures when parallelism arrives too early.

Save screenshots, console output, and a browser trace or HTML snapshot as failure artifacts. The test output says waitForText timed out; an artifact shows whether the customer saw a login redirect, a server error, or an invisible overlay. Run the small browser suite after fast unit and feature tests: static analysis and formatting, unit/feature tests in parallel, then browsers against a clean database. Add cross-browser or viewport coverage only where it buys confidence, such as a mobile payment widget.

Pitfalls, alternatives, and boundaries

Common mistakes are predictable: selectors tied to CSS details make redesigns expensive; real external APIs create flaky tests and can leak data; arbitrary delays hide races; shared seeds let tests pass accidentally; ignored console warnings can conceal failed chunk loads, hydration errors, or unhandled promises. A missing Chromium binary, port collision, or inaccessible database is a harness problem, not an application defect—fix the job image first.

Do not use a real browser for a pure pricing calculation, policy decision, JSON resource, or every Form Request branch. Unit tests make domain rules cheap to read; feature tests quickly exercise routes, middleware, validation, and persistence. Browser tests are the final selective layer. For broad regression coverage, a smoke test visiting a short list of pages and asserting no JavaScript errors may fit better. Screenshot comparison is a separate visual regression tool with a reviewed baseline. For hosted third-party checkout, fake the provider in CI and keep a small staging contract check under a segregated account.

A handful of browser tests then fails for useful reasons: they protect the journeys users pay you to keep working while the rest of the pyramid remains fast enough for every change.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.