Browser testing often feels like wrestling with flaky tests and mysterious failures. Laravel Dusk transforms this experience into something elegant and reliable. Beyond basic browser automation, Dusk offers advanced techniques that enable you to test complex user interactions, JavaScript-heavy applications, and ensure your application works flawlessly across different scenarios.
Table Of Contents
- Beyond Basic Browser Testing
- Advanced Selectors and Custom Methods
- JavaScript Execution and Interaction
- Advanced Waiting Strategies
- Multi-Browser and Parallel Testing
- Testing File Uploads and Downloads
- Mobile and Responsive Testing
- Visual Regression Testing
- Performance Testing with Dusk
- Debugging Failed Tests
- Conclusion
Beyond Basic Browser Testing
Laravel Dusk builds upon ChromeDriver to provide expressive, easy-to-use browser automation. While many developers stop at simple form submissions and page visits, Dusk's true power lies in its advanced capabilities – custom selectors, JavaScript execution, multi-browser testing, and sophisticated wait strategies that eliminate flaky tests.
Understanding these advanced techniques transforms browser testing from a necessary evil into a powerful tool for ensuring application quality. Let's explore how to leverage Dusk's full potential for testing modern, interactive web applications.
Advanced Selectors and Custom Methods
Dusk's selector engine goes beyond basic CSS selectors. Create maintainable tests with custom selectors:
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Page;
class DashboardPage extends Page
{
public function url()
{
return '/dashboard';
}
public function elements()
{
return [
'@stats-widget' => '[data-test="stats-widget"]',
'@user-menu' => '[data-test="user-menu"]',
'@notification-bell' => '[data-test="notification-bell"]',
];
}
// Custom selector methods
public function selectDateRange(Browser $browser, $start, $end)
{
$browser->click('@date-picker')
->waitFor('.date-picker-popup')
->click("[data-date='{$start}']")
->click("[data-date='{$end}']")
->click('@apply-dates');
}
// Complex interaction patterns
public function waitForChartData(Browser $browser)
{
$browser->waitUntil('
document.querySelector(".chart-container canvas") &&
window.chartInstance &&
window.chartInstance.data.datasets[0].data.length > 0
', 10);
}
}
Creating Reusable Components
Build reusable component abstractions for common UI patterns:
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class DataTable extends BaseComponent
{
protected $selector;
public function __construct($selector = '.data-table')
{
$this->selector = $selector;
}
public function selector()
{
return $this->selector;
}
public function elements()
{
return [
'@search' => 'input[type="search"]',
'@rows' => 'tbody tr',
'@pagination' => '.pagination',
'@sort-icons' => 'th .sort-icon',
];
}
public function searchFor(Browser $browser, $term)
{
$browser->type('@search', $term)
->pause(300); // Debounce delay
}
public function sortBy(Browser $browser, $column)
{
$browser->click("th[data-column='{$column}']")
->waitForReload();
}
public function assertRowContains(Browser $browser, $text)
{
$browser->assertSeeIn('@rows', $text);
}
public function selectRow(Browser $browser, $index)
{
$browser->click("tbody tr:nth-child({$index}) input[type='checkbox']");
}
}
// Usage in tests
$browser->within(new DataTable, function ($table) {
$table->searchFor('John Doe')
->assertRowContains('john@example.com')
->sortBy('created_at');
});
JavaScript Execution and Interaction
Testing JavaScript-heavy applications requires executing code within the browser context:
<?php
namespace Tests\Browser;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
class JavaScriptInteractionTest extends DuskTestCase
{
public function testComplexJavaScriptInteractions()
{
$this->browse(function (Browser $browser) {
$browser->visit('/interactive-dashboard')
// Execute JavaScript and capture results
->script([
'window.testData = { loaded: false };',
'document.addEventListener("data-loaded", () => {
window.testData.loaded = true;
window.testData.itemCount = document.querySelectorAll(".item").length;
});'
]);
// Trigger async operation
$browser->click('@load-data')
->waitUntil('window.testData.loaded === true', 10);
// Verify JavaScript state
$itemCount = $browser->script('return window.testData.itemCount;')[0];
$this->assertGreaterThan(0, $itemCount);
// Interact with JavaScript libraries
$browser->script([
'// Interact with Chart.js',
'window.chartInstance.data.datasets[0].data = [10, 20, 30];',
'window.chartInstance.update();'
])->pause(1000);
// Test drag and drop with JavaScript
$browser->script([
'const draggable = document.querySelector(".draggable-item");',
'const dropZone = document.querySelector(".drop-zone");',
'const dataTransfer = new DataTransfer();',
'',
'// Simulate drag start',
'const dragStartEvent = new DragEvent("dragstart", {',
' dataTransfer: dataTransfer,',
' bubbles: true',
'});',
'draggable.dispatchEvent(dragStartEvent);',
'',
'// Simulate drop',
'const dropEvent = new DragEvent("drop", {',
' dataTransfer: dataTransfer,',
' bubbles: true',
'});',
'dropZone.dispatchEvent(dropEvent);'
])->assertSeeIn('.drop-zone', 'Item dropped');
});
}
}
Advanced Waiting Strategies
Eliminate flaky tests with sophisticated wait conditions:
<?php
namespace Tests\Browser\Concerns;
use Laravel\Dusk\Browser;
use Facebook\WebDriver\WebDriverBy;
trait AdvancedWaiting
{
protected function waitForAnimation(Browser $browser, $selector)
{
$browser->script([
"const element = document.querySelector('{$selector}');",
"const animation = element.getAnimations()[0];",
"if (animation) await animation.finished;"
]);
}
protected function waitForNetworkIdle(Browser $browser, $timeout = 5)
{
$browser->script([
'window.duskNetworkIdle = false;',
'let pendingRequests = 0;',
'',
'const observer = new PerformanceObserver((list) => {',
' for (const entry of list.getEntries()) {',
' if (entry.name.includes("/api/")) {',
' pendingRequests++;',
' fetch(entry.name).finally(() => {',
' pendingRequests--;',
' if (pendingRequests === 0) {',
' window.duskNetworkIdle = true;',
' }',
' });',
' }',
' }',
'});',
'observer.observe({ entryTypes: ["resource"] });'
]);
$browser->waitUntil('window.duskNetworkIdle === true', $timeout);
}
protected function waitForLazyLoad(Browser $browser, $selector)
{
$browser->script([
"const images = document.querySelectorAll('{$selector} img[loading=\"lazy\"]');",
"const imagePromises = Array.from(images).map(img => {",
" if (img.complete) return Promise.resolve();",
" return new Promise(resolve => {",
" img.addEventListener('load', resolve);",
" img.addEventListener('error', resolve);",
" });",
"});",
"await Promise.all(imagePromises);"
]);
}
}
Multi-Browser and Parallel Testing
Test complex interactions between multiple users:
<?php
namespace Tests\Browser;
use App\Models\User;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
class CollaborationTest extends DuskTestCase
{
public function testRealtimeCollaboration()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->browse(function (Browser $first, Browser $second) use ($user1, $user2) {
// User 1 starts editing
$first->loginAs($user1)
->visit('/documents/1/edit')
->waitFor('@editor')
->type('@editor', 'Hello from User 1');
// User 2 joins the document
$second->loginAs($user2)
->visit('/documents/1/edit')
->waitFor('@editor')
->waitForText('Hello from User 1')
->assertSee('User 1 is editing');
// Test collaborative cursor
$first->click('@editor')
->keys('@editor', '{end}');
$second->waitUntil('
document.querySelector(".remote-cursor[data-user=\''.$user1->id.'\']") !== null
')->assertVisible(".remote-cursor[data-user='{$user1->id}']");
// Test conflict resolution
$first->type('@editor', ' - First user addition');
$second->type('@editor', ' - Second user addition');
$first->pause(1000)->assertSee('Second user addition');
$second->pause(1000)->assertSee('First user addition');
});
}
public function testConcurrentFormSubmission()
{
$this->browse(function (Browser $first, Browser $second) {
$first->visit('/limited-resource')
->type('@quantity', '5')
->press('@submit');
$second->visit('/limited-resource')
->type('@quantity', '8')
->press('@submit');
// One should succeed, one should fail
$first->waitForLocation('/success', 5)
->assertPathIs('/success');
$second->waitFor('.error-message', 5)
->assertSee('Resource no longer available');
});
}
}
Testing File Uploads and Downloads
Handle complex file operations in browser tests:
<?php
namespace Tests\Browser;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
use Illuminate\Support\Facades\Storage;
class FileOperationTest extends DuskTestCase
{
public function testAdvancedFileUpload()
{
$this->browse(function (Browser $browser) {
$browser->visit('/upload')
// Test drag and drop upload
->script([
'const dropZone = document.querySelector(".upload-zone");',
'const file = new File(["test content"], "test.txt", {type: "text/plain"});',
'const dataTransfer = new DataTransfer();',
'dataTransfer.items.add(file);',
'',
'const dropEvent = new DragEvent("drop", {',
' dataTransfer: dataTransfer,',
' bubbles: true,',
' cancelable: true',
'});',
'dropZone.dispatchEvent(dropEvent);'
])
->waitForText('test.txt')
->assertSee('1 file ready for upload');
// Test multiple file selection with validation
$browser->attach('input[type="file"]', [
__DIR__.'/fixtures/image1.jpg',
__DIR__.'/fixtures/image2.jpg',
__DIR__.'/fixtures/document.pdf',
])->waitForText('3 files selected');
// Test file preview generation
$browser->waitUntil('
document.querySelectorAll(".file-preview img").length === 2
')->assertVisible('.file-preview img');
// Test upload progress
$browser->press('@upload-button')
->waitFor('.progress-bar')
->waitUntil('
parseFloat(document.querySelector(".progress-bar").style.width) > 0
')
->waitUntil('
parseFloat(document.querySelector(".progress-bar").style.width) === 100
', 30);
});
}
public function testFileDownload()
{
$downloadPath = storage_path('dusk-downloads');
// Configure Chrome for downloads
Browser::$storeConsoleLogAt = function () use ($downloadPath) {
return $downloadPath;
};
$this->browse(function (Browser $browser) use ($downloadPath) {
$browser->visit('/reports')
->click('@generate-report')
->waitFor('@download-button', 10)
->click('@download-button');
// Wait for download to complete
$browser->pause(2000);
// Verify downloaded file
$files = glob($downloadPath . '/*.csv');
$this->assertCount(1, $files);
$content = file_get_contents($files[0]);
$this->assertStringContainsString('Report Generated', $content);
// Clean up
array_map('unlink', $files);
});
}
}
Mobile and Responsive Testing
Test responsive designs and mobile-specific features:
<?php
namespace Tests\Browser;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
use Facebook\WebDriver\Chrome\ChromeOptions;
class ResponsiveTest extends DuskTestCase
{
protected function driver()
{
$options = (new ChromeOptions)->addArguments([
'--disable-gpu',
'--headless',
'--window-size=375,812', // iPhone X dimensions
'--user-agent=Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X)'
]);
return RemoteWebDriver::create(
'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
public function testMobileNavigation()
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->assertMissing('.desktop-nav')
->assertVisible('.mobile-menu-toggle')
->click('.mobile-menu-toggle')
->waitFor('.mobile-menu')
->assertVisible('.mobile-menu')
->click('.mobile-menu a[href="/features"]')
->assertPathIs('/features');
// Test touch interactions
$browser->script([
'const element = document.querySelector(".swipeable-gallery");',
'const touch = new Touch({',
' identifier: Date.now(),',
' target: element,',
' clientX: 300,',
' clientY: 200,',
' radiusX: 2.5,',
' radiusY: 2.5,',
' rotationAngle: 0,',
' force: 0.5,',
'});',
'',
'const touchStart = new TouchEvent("touchstart", {',
' touches: [touch],',
' targetTouches: [touch],',
' changedTouches: [touch],',
' bubbles: true',
'});',
'element.dispatchEvent(touchStart);',
'',
'// Simulate swipe',
'const touchMove = new TouchEvent("touchmove", {',
' touches: [touch],',
' targetTouches: [touch],',
' changedTouches: [touch],',
' bubbles: true',
'});',
'touch.clientX = 100;',
'element.dispatchEvent(touchMove);'
])->pause(500)
->assertVisible('.gallery-item:nth-child(2)');
});
}
}
Visual Regression Testing
Implement visual regression testing with Dusk:
<?php
namespace Tests\Browser;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
use Spatie\Browsershot\Browsershot;
class VisualRegressionTest extends DuskTestCase
{
protected $screenshotPath;
protected function setUp(): void
{
parent::setUp();
$this->screenshotPath = storage_path('app/screenshots');
}
public function testVisualRegression()
{
$this->browse(function (Browser $browser) {
$pages = [
'/' => 'homepage',
'/features' => 'features',
'/pricing' => 'pricing',
];
foreach ($pages as $path => $name) {
$browser->visit($path)
->waitFor('body')
->script('document.body.style.overflow = "hidden";'); // Prevent scrollbars
// Take screenshot
$browser->screenshot($name);
// Compare with baseline
if (file_exists("{$this->screenshotPath}/baseline/{$name}.png")) {
$this->assertScreenshotMatches($name);
} else {
// Save as new baseline
copy(
"{$this->screenshotPath}/{$name}.png",
"{$this->screenshotPath}/baseline/{$name}.png"
);
}
}
});
}
protected function assertScreenshotMatches($name, $threshold = 5)
{
$current = imagecreatefrompng("{$this->screenshotPath}/{$name}.png");
$baseline = imagecreatefrompng("{$this->screenshotPath}/baseline/{$name}.png");
$diff = imagecreatetruecolor(imagesx($current), imagesy($current));
$pixelsDiff = 0;
for ($x = 0; $x < imagesx($current); $x++) {
for ($y = 0; $y < imagesy($current); $y++) {
$currentPixel = imagecolorat($current, $x, $y);
$baselinePixel = imagecolorat($baseline, $x, $y);
if ($currentPixel !== $baselinePixel) {
$pixelsDiff++;
imagesetpixel($diff, $x, $y, 0xFF0000); // Red for differences
}
}
}
$percentDiff = ($pixelsDiff / (imagesx($current) * imagesy($current))) * 100;
if ($percentDiff > $threshold) {
imagepng($diff, "{$this->screenshotPath}/diff/{$name}.png");
$this->fail("Visual regression detected: {$percentDiff}% difference");
}
}
}
Performance Testing with Dusk
Measure and assert performance metrics:
<?php
namespace Tests\Browser;
use Tests\DuskTestCase;
use Laravel\Dusk\Browser;
class PerformanceTest extends DuskTestCase
{
public function testPageLoadPerformance()
{
$this->browse(function (Browser $browser) {
$browser->visit('/')
->script([
'window.performanceMetrics = {',
' navigation: performance.getEntriesByType("navigation")[0],',
' resources: performance.getEntriesByType("resource"),',
' firstPaint: performance.getEntriesByName("first-paint")[0],',
' firstContentfulPaint: performance.getEntriesByName("first-contentful-paint")[0]',
'};'
]);
$metrics = $browser->script('return window.performanceMetrics;')[0];
// Assert load time is under 3 seconds
$this->assertLessThan(3000, $metrics['navigation']['loadEventEnd']);
// Assert First Contentful Paint is under 1.5 seconds
$this->assertLessThan(1500, $metrics['firstContentfulPaint']['startTime']);
// Check resource loading
$jsResources = array_filter($metrics['resources'], function ($resource) {
return strpos($resource['name'], '.js') !== false;
});
foreach ($jsResources as $resource) {
$this->assertLessThan(1000, $resource['duration'],
"JavaScript file {$resource['name']} took too long to load");
}
});
}
public function testMemoryLeaks()
{
$this->browse(function (Browser $browser) {
$browser->visit('/dashboard');
// Get initial memory usage
$initialMemory = $browser->script('return performance.memory.usedJSHeapSize;')[0];
// Perform memory-intensive operations
for ($i = 0; $i < 10; $i++) {
$browser->click('@refresh-data')
->waitFor('.data-loaded')
->pause(500);
}
// Force garbage collection
$browser->script('if (window.gc) window.gc();');
$browser->pause(1000);
// Check final memory usage
$finalMemory = $browser->script('return performance.memory.usedJSHeapSize;')[0];
// Memory shouldn't increase by more than 50%
$this->assertLessThan($initialMemory * 1.5, $finalMemory,
'Potential memory leak detected');
});
}
}
Debugging Failed Tests
Advanced debugging techniques for complex test failures:
<?php
namespace Tests\Browser\Concerns;
use Laravel\Dusk\Browser;
trait AdvancedDebugging
{
protected function debugBrowserState(Browser $browser)
{
// Capture comprehensive debug information
$debugInfo = $browser->script([
'return {',
' url: window.location.href,',
' cookies: document.cookie,',
' localStorage: Object.entries(localStorage),',
' sessionStorage: Object.entries(sessionStorage),',
' errors: window.duskErrors || [],',
' networkErrors: window.duskNetworkErrors || [],',
' consoleErrors: window.duskConsoleErrors || [],',
' dom: {',
' bodyClasses: document.body.className,',
' activeElement: document.activeElement?.tagName,',
' forms: Array.from(document.forms).map(f => ({',
' action: f.action,',
' method: f.method,',
' fields: Array.from(f.elements).map(e => e.name)',
' }))',
' }',
'};'
])[0];
// Log debug information
logger()->debug('Dusk Test Debug Info', $debugInfo);
// Take annotated screenshot
$browser->script([
'// Add debug overlays',
'document.querySelectorAll("[data-test]").forEach(el => {',
' el.style.outline = "2px solid red";',
' const label = document.createElement("div");',
' label.textContent = el.getAttribute("data-test");',
' label.style.cssText = "position:absolute;background:red;color:white;padding:2px;font-size:10px;z-index:9999";',
' el.style.position = "relative";',
' el.appendChild(label);',
'});'
]);
$browser->screenshot('debug_' . time());
}
protected function setupErrorCapture(Browser $browser)
{
$browser->script([
'// Capture JavaScript errors',
'window.duskErrors = [];',
'window.addEventListener("error", (e) => {',
' window.duskErrors.push({',
' message: e.message,',
' filename: e.filename,',
' line: e.lineno,',
' column: e.colno,',
' stack: e.error?.stack',
' });',
'});',
'',
'// Capture network errors',
'window.duskNetworkErrors = [];',
'const observer = new PerformanceObserver((list) => {',
' for (const entry of list.getEntries()) {',
' if (entry.responseStatus >= 400) {',
' window.duskNetworkErrors.push({',
' url: entry.name,',
' status: entry.responseStatus,',
' duration: entry.duration',
' });',
' }',
' }',
'});',
'observer.observe({ entryTypes: ["resource"] });',
'',
'// Capture console errors',
'window.duskConsoleErrors = [];',
'const originalError = console.error;',
'console.error = function(...args) {',
' window.duskConsoleErrors.push(args);',
' originalError.apply(console, args);',
'};'
]);
}
}
Conclusion
Laravel Dusk's advanced techniques transform browser testing from a fragile afterthought into a robust quality assurance tool. By mastering custom selectors, JavaScript execution, multi-browser testing, and sophisticated wait strategies, you can create tests that reliably verify even the most complex user interactions.
Remember that great browser tests mirror real user behavior. Use Page Objects for maintainability, implement proper wait strategies to eliminate flakiness, and leverage Dusk's full capabilities to test every aspect of your application. With these advanced techniques, your browser tests become a reliable safety net that catches issues before your users do.
Add Comment
No comments yet. Be the first to comment!