0

Testing authentication — actingAs(), Sanctum::actingAs()

Intermediate5 min read·lv-26-007

Concept

Browser testing with Laravel Dusk automates real browser interactions — clicking buttons, filling forms, navigating pages — using ChromeDriver and Selenium. Unlike HTTP tests which bypass JavaScript, Dusk tests the full browser experience.

Installation: composer require --dev laravel/dusk then php artisan dusk:install. Installs ChromeDriver, creates tests/Browser/ directory.

Running: php artisan dusk. Requires Chrome installed. Uses the .env.dusk.local file if it exists (so tests don't pollute your dev database).

Browser class methods:

  • ->visit('/url'): Navigate to a URL.
  • ->click('.selector'): Click an element.
  • ->type('#input', 'value'): Type in an input.
  • ->press('Submit'): Click a button by text.
  • ->assertSee('text'): Assert text is visible.
  • ->assertPathIs('/dashboard'): Assert current URL path.
  • ->waitFor('.selector'): Wait for an element to appear (for JavaScript).
  • ->waitUntilMissing('.loader'): Wait for element to disappear.
  • ->screenshot('name'): Save a screenshot (saved to tests/Browser/screenshots/).

Authentication: ->loginAs($user) — sets the session without filling the login form.

Pages: php artisan dusk:page LoginPage — creates a page object with URL, assert, and element shortcut methods.

Dusk vs HTTP tests: HTTP tests are 100x faster. Only use Dusk for interactions requiring JavaScript (SPAs, Vue/React components, complex forms with live validation). Everything else: HTTP tests.

Code Example

php
<?php
namespace Tests\Browser;

use App\Models\User;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;

class LoginTest extends DuskTestCase
{
    use DatabaseMigrations; // dusk always runs real migrations

    public function test_user_can_login_via_browser(): void
    {
        $user = User::factory()->create(['password' => bcrypt('secret')]);

        $this->browse(function(Browser $browser) use ($user) {
            $browser->visit('/login')
                    ->type('#email', $user->email)
                    ->type('#password', 'secret')
                    ->press('Sign in')
                    ->waitForLocation('/dashboard')
                    ->assertSee('Welcome back')
                    ->assertPathIs('/dashboard');
        });
    }

    public function test_login_shows_error_for_wrong_password(): void
    {
        $user = User::factory()->create();

        $this->browse(function(Browser $browser) use ($user) {
            $browser->visit('/login')
                    ->type('#email', $user->email)
                    ->type('#password', 'wrongpassword')
                    ->press('Sign in')
                    ->waitFor('.error-message')
                    ->assertSee('credentials do not match');
        });
    }

    public function test_authenticated_user_can_post_comment(): void
    {
        $user = User::factory()->create();
        $post = \App\Models\Post::factory()->published()->create();

        $this->browse(function(Browser $browser) use ($user, $post) {
            $browser->loginAs($user)             // bypass login form
                    ->visit("/posts/{$post->slug}")
                    ->type('.comment-textarea', 'Great post!')
                    ->press('Post Comment')
                    ->waitFor('.comment-list .comment') // wait for JS to add comment
                    ->assertSee('Great post!');
        });
    }

    public function test_vue_component_updates_dynamically(): void
    {
        $user = User::factory()->create();

        $this->browse(function(Browser $browser) use ($user) {
            $browser->loginAs($user)
                    ->visit('/profile')
                    ->type('@name-input', 'New Name')  // @name-input = dusk="name-input" attribute
                    ->press('@save-button')
                    ->waitUntilMissing('@loading-spinner')
                    ->assertSee('Profile saved!');
        });
    }
}