HardПрактика8 min

Browser Tests (Laravel Dusk)

Laravel Dusk, ChromeDriver, создание браузеров, взаимодействие с элементами, ожидания, pages, components, CI/CD

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)
    ;
<?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');
});

Проверь себя

Как Dusk создаёт несколько независимых браузеров в одном тесте?

Что означает селектор '@email' в контексте Dusk?

Какой метод вызывается автоматически при посещении Page Object?

Чем отличается assertVisible() от assertPresent() в Dusk?