Testing authentication — actingAs(), Sanctum::actingAs()
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 totests/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
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!');
});
}
}