Laravel Dusk предоставляет API для автоматизированного тестирования в реальном браузере (Chrome/Chromium). Dusk использует ChromeDriver для управления браузером.
Установка и настройка
<?php
// Installation
// composer require laravel/dusk --dev
// php artisan dusk:install
// This creates:
// - tests/Browser directory
// - tests/Browser/Pages directory
// - tests/Browser/Components directory
// - tests/DuskTestCase.php
DuskTestCase базовый класс
<?php
declare(strict_types=1);
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Laravel\Dusk\TestCase as BaseTestCase;
abstract class DuskTestCase extends BaseTestCase
{
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions())->addArguments(
collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
])->unless($this->hasHeadlessDisabled(), function ($items) {
return $items->merge([
'--disable-gpu',
'--headless=new', // New headless mode
]);
})->all()
);
return RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
}
Окружение для Dusk
<?php
// .env.dusk.local
// APP_URL=http://localhost:8000
// DB_DATABASE=testing
// SESSION_DRIVER=file
// Run Dusk tests
// php artisan dusk
// php artisan dusk --filter=LoginTest
// php artisan dusk tests/Browser/LoginTest.php
Создание тестов
Базовый browser-тест
<?php
declare(strict_types=1);
namespace Tests\Browser;
use App\Models\User;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class LoginTest extends DuskTestCase
{
public function test_user_can_login(): void
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => bcrypt('password'),
]);
$this->browse(function (Browser $browser) {
$browser->visit('/login')
->type('email', '[email protected]')
->type('password', 'password')
->press('Log in')
->assertPathIs('/dashboard')
->assertSee('Welcome');
});
}
public function test_invalid_login_shows_error(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/login')
->type('email', '[email protected]')
->type('password', 'wrongpassword')
->press('Log in')
->assertPathIs('/login')
->assertSee('These credentials do not match');
});
}
}
Множественные браузеры
<?php
public function test_real_time_messaging(): void
{
$userA = User::factory()->create();
$userB = User::factory()->create();
$this->browse(function (Browser $browserA, Browser $browserB) use ($userA, $userB) {
// First browser: User A
$browserA->loginAs($userA)
->visit('/chat')
->waitFor('.chat-window');
// Second browser: User B
$browserB->loginAs($userB)
->visit('/chat')
->waitFor('.chat-window')
->type('.message-input', 'Hello from User B!')
->press('Send');
// Verify User A sees the message
$browserA->waitForText('Hello from User B!')
->assertSee('Hello from User B!');
});
}
Подвох на экзамене: Dusk поддерживает множественные аргументы Browser в browse() callback. Каждый аргумент создаёт отдельный экземпляр браузера. Это полезно для тестирования real-time функций.
Взаимодействие с элементами
Клики и навигация
<?php
$browser->visit('/page')
->clickLink('About Us') // Click anchor by text
->click('.btn-primary') // Click by CSS selector
->click('#submit-button') // Click by ID
->clickAtXPath('//button[@type="submit"]') // Click by XPath
->clickAtPoint(100, 200) // Click at coordinates
->doubleClick('.item') // Double click
->rightClick('.context-menu') // Right click
->clickAndHold('.draggable') // Click and hold
->releaseMouse(); // Release mouse
Ввод текста
<?php
$browser
->type('name', 'John Doe') // Type into input by name attribute
->type('#email', '[email protected]') // Type into input by CSS selector
->typeSlowly('search', 'Laravel', 100) // Type slowly (100ms per char)
->clear('name') // Clear input
->append('name', ' Jr.') // Append to existing value
->keys('#search', 'Laravel') // Raw keyboard input
->keys('#search', '{enter}') // Press Enter
->keys('#editor', ['{ctrl}', 'a']) // Ctrl+A (select all)
;
Dropdowns и Select
<?php
$browser
->select('country', 'US') // Select by value
->select('country') // Select random option
->select('#role', 'admin') // Select by CSS selector
;
Checkboxes и Radio
<?php
$browser
->check('terms') // Check checkbox by name
->check('#agree') // Check by selector
->uncheck('newsletter') // Uncheck checkbox
->radio('plan', 'premium') // Select radio by name and value
;
File Upload
<?php
$browser
->attach('photo', __DIR__ . '/fixtures/avatar.jpg') // Attach file
->attach('#document', __DIR__ . '/fixtures/doc.pdf') // By selector
;
Assertions (проверки)
Text и Content
<?php
$browser
->assertSee('Welcome') // Text visible on page
->assertDontSee('Error') // Text NOT visible
->assertSeeIn('.alert', 'Success') // Text within element
->assertDontSeeIn('.alert', 'Error') // Text NOT within element
->assertSourceHas('<meta name="csrf"') // In page source
->assertSourceMissing('<script>alert') // NOT in page source
;
URL и Path
<?php
$browser
->assertPathIs('/dashboard') // Exact path
->assertPathIsNot('/login') // Not this path
->assertPathBeginsWith('/admin') // Path starts with
->assertRouteIs('dashboard') // Named route
->assertQueryStringHas('page') // Query param exists
->assertQueryStringHas('page', '2') // Query param value
->assertQueryStringMissing('filter') // No query param
->assertFragmentIs('section-1') // URL fragment
->assertFragmentIsNot('section-2') // Not this fragment
->assertUrlIs('http://app.test/dashboard') // Full URL
;
Form Elements
<?php
$browser
->assertInputValue('name', 'John') // Input has value
->assertInputValueIsNot('name', 'Jane') // Input doesn't have value
->assertChecked('terms') // Checkbox is checked
->assertNotChecked('newsletter') // Checkbox not checked
->assertRadioSelected('plan', 'premium') // Radio selected
->assertRadioNotSelected('plan', 'free') // Radio not selected
->assertSelected('country', 'US') // Select has value
->assertNotSelected('country', 'UK') // Select doesn't have value
;
Element Visibility
<?php
$browser
->assertVisible('.modal') // Element is visible
->assertMissing('.modal') // Element not in DOM
->assertPresent('.hidden-input') // In DOM (maybe hidden)
->assertNotPresent('.removed') // Not in DOM at all
->assertEnabled('submit') // Element is enabled
->assertDisabled('submit') // Element is disabled
->assertFocused('email') // Element has focus
->assertNotFocused('name') // Element doesn't have focus
;
Vue Assertions
<?php
$browser
->assertVue('user.name', 'John', '@user-component') // Vue data
->assertVueIsNot('loading', true, '@loader') // Vue data !== value
->assertVueContains('items', 'Laravel', '@list') // Array contains
->assertVueDoesNotContain('items', 'Rails', '@list') // Array doesn't contain
;
Ожидания (Waiting)
<?php
$browser
// Wait for element
->waitFor('.modal') // Wait until visible
->waitFor('.modal', 10) // Custom timeout (seconds)
->waitUntilMissing('.loading-spinner') // Wait until removed
// Wait for text
->waitForText('Data loaded')
->waitUntilMissingText('Loading...')
// Wait for URL/location
->waitForLocation('/dashboard')
->waitForRoute('dashboard')
// Wait with callback
->waitUsing(10, 500, function () use ($browser) {
return $browser->resolver->findOrFail('.loaded') !== null;
}, 'Element never appeared')
// Wait for JavaScript
->waitUntil('window.appReady === true')
// Wait for Vue
->waitForEvent('loaded', '@component')
// Pause (fixed delay -- avoid in production tests!)
->pause(1000) // 1 second
;
Подвох на экзамене: По умолчанию timeout для waitFor -- 5 секунд. Для медленных операций передавайте второй аргумент. pause() следует использовать только для отладки.
Pages (Page Objects)
Page Objects инкапсулируют URL и взаимодействия для конкретной страницы:
<?php
declare(strict_types=1);
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Page;
class LoginPage extends Page
{
// URL of this page
public function url(): string
{
return '/login';
}
// Assert we're on the right page
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('Log in');
}
// Page-specific selectors (shortcuts)
public function elements(): array
{
return [
'@email' => 'input[name="email"]',
'@password' => 'input[name="password"]',
'@submit' => 'button[type="submit"]',
'@error' => '.alert-danger',
];
}
// Custom page methods
public function loginAs(Browser $browser, string $email, string $password): void
{
$browser->type('@email', $email)
->type('@password', $password)
->click('@submit');
}
// Failed login helper
public function attemptLogin(Browser $browser, string $email, string $password): void
{
$browser->type('@email', $email)
->type('@password', $password)
->click('@submit')
->waitFor('@error');
}
}
Использование:
<?php
use Tests\Browser\Pages\LoginPage;
public function test_login_page(): void
{
$user = User::factory()->create([
'email' => '[email protected]',
'password' => bcrypt('secret'),
]);
$this->browse(function (Browser $browser) {
$browser->visit(new LoginPage())
->loginAs('[email protected]', 'secret')
->assertPathIs('/dashboard');
});
}
Подвох на экзамене: Селекторы, определённые в elements() через @, -- это Dusk-shortcut. @email заменяется на фактический CSS-селектор input[name="email"] при использовании.
Components (Dusk Components)
Компоненты для переиспользуемых UI-элементов:
<?php
declare(strict_types=1);
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class DatePicker extends BaseComponent
{
// Root selector for this component
public function selector(): string
{
return '.date-picker';
}
// Assert component is on page
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector());
}
// Component elements
public function elements(): array
{
return [
'@date-input' => 'input.date-field',
'@calendar' => '.calendar-popup',
'@month-select' => '.month-selector',
];
}
// Select a date
public function selectDate(Browser $browser, int $year, int $month, int $day): void
{
$browser->click('@date-input')
->waitFor('@calendar')
->select('@month-select', (string) $month)
->click(".day-{$day}")
->waitUntilMissing('@calendar');
}
}
<?php
// Usage in test
public function test_event_creation_with_date(): void
{
$this->browse(function (Browser $browser) {
$browser->loginAs(User::factory()->create())
->visit('/events/create')
->type('title', 'Laravel Conference')
->within(new DatePicker(), function (Browser $browser) {
$browser->selectDate(2024, 6, 15);
})
->press('Create Event')
->assertSee('Event created!');
});
}
Screenshots и Console Log
<?php
$browser
->screenshot('login-page') // Save screenshot
->responsiveScreenshots('homepage') // Multiple resolutions
;
// Screenshots are saved automatically on failure
// Location: tests/Browser/screenshots/
// Console log
$browser
->dump() // Dump page source
->tinker() // Interactive debugging
;
// Access browser console log
public function test_no_js_errors(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->assertNoConsoleErrors(); // No JS errors in console
});
}
CI/CD настройка
GitHub Actions
# .github/workflows/dusk.yml
name: Dusk Tests
on: [push, pull_request]
jobs:
dusk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Dependencies
run: composer install --no-progress
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Start ChromeDriver
run: ./vendor/laravel/dusk/bin/chromedriver-linux &
- name: Run Laravel Server
run: php artisan serve --no-reload &
- name: Run Dusk Tests
env:
APP_URL: http://127.0.0.1:8000
run: php artisan dusk
- name: Upload Screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: dusk-screenshots
path: tests/Browser/screenshots
Практические советы
<?php
// Authentication helper
$browser->loginAs(User::find(1)); // Logs in without visiting login page
// Resize browser
$browser->resize(1920, 1080);
$browser->maximize();
// Execute JavaScript
$browser->script('window.scrollTo(0, 500)');
$result = $browser->script('return document.title');
// Drag and drop
$browser->drag('.item-1', '.dropzone');
$browser->dragUp('.slider', 100);
$browser->dragDown('.slider', 100);
$browser->dragLeft('.carousel', 200);
$browser->dragRight('.carousel', 200);
// Frames
$browser->withinFrame('#iframe-id', function (Browser $browser) {
$browser->assertSee('Content in iframe');
});