Advanced Testing: Browser & Integration Tests
Advanced Testing: Browser & Integration Tests
Beyond unit tests, comprehensive testing strategies include browser automation for end-to-end testing and integration tests that verify multiple components work together. Laravel Dusk provides expressive browser automation, Pest PHP offers elegant testing syntax, and parallel testing capabilities dramatically speed up test suites. This lesson covers browser testing with Dusk, modern testing with Pest, running tests in parallel, CI/CD integration patterns, and measuring test coverage.
Laravel Dusk for Browser Testing
Laravel Dusk provides an expressive browser automation and testing API. It drives a real Chrome or Firefox browser, allowing you to test JavaScript-heavy applications and user interactions.
# Install Laravel Dusk
composer require --dev laravel/dusk
# Install Dusk in your application
php artisan dusk:install
# Run Dusk tests
php artisan dusk
# Run specific test
php artisan dusk tests/Browser/LoginTest.php
<?php
namespace Tests\Browser;
use App\Models\User;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class LoginTest extends DuskTestCase
{
// Basic navigation and assertions
public function test_user_can_login()
{
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit('/login')
->type('email', $user->email)
->type('password', 'password')
->press('Login')
->assertPathIs('/dashboard')
->assertSee('Welcome');
});
}
// Multiple browsers for testing real-time features
public function test_chat_between_users()
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$this->browse(function (Browser $first, Browser $second) use ($user1, $user2) {
// First user logs in and sends message
$first->loginAs($user1)
->visit('/chat')
->type('#message-input', 'Hello from User 1')
->press('#send-button')
->assertSee('Hello from User 1');
// Second user logs in and sees the message
$second->loginAs($user2)
->visit('/chat')
->waitForText('Hello from User 1', 5)
->assertSee('Hello from User 1')
->type('#message-input', 'Hello back!')
->press('#send-button');
// First user sees the reply
$first->waitForText('Hello back!', 5)
->assertSee('Hello back!');
});
}
// Waiting for elements and JavaScript
public function test_dynamic_content_loading()
{
$this->browse(function (Browser $browser) {
$browser->visit('/products')
->waitFor('#product-list', 10) // Wait up to 10 seconds
->assertVisible('#product-list')
->waitForText('Product Name')
->click('#load-more')
->pause(1000) // Pause for 1 second
->waitUntilMissing('.loading-spinner')
->assertSeeIn('#product-count', '20 products');
});
}
// Form interactions
public function test_create_product_with_validation()
{
$user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user) {
$browser->loginAs($user)
->visit('/products/create')
// Submit empty form to trigger validation
->press('Create Product')
->assertSee('The name field is required')
->assertSee('The price field is required')
// Fill form correctly
->type('name', 'Test Product')
->type('price', '99.99')
->select('category', 'electronics')
->attach('image', __DIR__ . '/test-image.jpg')
->check('is_featured')
->press('Create Product')
->assertPathIs('/products')
->assertSee('Product created successfully')
->assertSee('Test Product');
});
}
// JavaScript execution and evaluation
public function test_javascript_interactions()
{
$this->browse(function (Browser $browser) {
$browser->visit('/calculator')
->click('#button-2')
->click('#button-plus')
->click('#button-3')
->click('#button-equals')
->assertScript('document.querySelector("#display").value === "5"')
->script('window.scrollTo(0, 500)')
->assertScript('window.pageYOffset >= 500');
});
}
// File downloads
public function test_download_report()
{
$user = User::factory()->create();
$this->browse(function (Browser $browser) use ($user) {
$browser->loginAs($user)
->visit('/reports')
->click('#download-csv')
->pause(2000);
$downloads = glob(storage_path('app/downloads/*.csv'));
$this->assertNotEmpty($downloads);
$this->assertStringContainsString('report', end($downloads));
});
}
// Taking screenshots for debugging
public function test_with_screenshots()
{
$this->browse(function (Browser $browser) {
$browser->visit('/complex-page')
->screenshot('initial-state')
->click('#trigger-modal')
->screenshot('modal-opened')
->assertVisible('#modal');
});
// Screenshots saved to tests/Browser/screenshots/
}
// Resize window for responsive testing
public function test_mobile_navigation()
{
$this->browse(function (Browser $browser) {
$browser->resize(375, 667) // iPhone dimensions
->visit('/')
->assertMissing('#desktop-nav')
->assertVisible('#mobile-nav')
->click('#hamburger-menu')
->assertVisible('#mobile-menu')
->clickLink('About')
->assertPathIs('/about');
});
}
// Page components (reusable selectors)
public function test_using_page_components()
{
$this->browse(function (Browser $browser) {
$browser->visit(new Pages\Dashboard)
->assertSee('Dashboard')
->within(new Components\Navigation, function ($browser) {
$browser->clickLink('Settings');
})
->on(new Pages\Settings)
->assertSee('Settings');
});
}
protected function tearDown(): void
{
// Clean up test data
foreach (static::$browsers as $browser) {
$browser->quit();
}
parent::tearDown();
}
}
// Dusk Page class for reusable page logic
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Page;
class Dashboard extends Page
{
public function url()
{
return '/dashboard';
}
public function assert(Browser $browser)
{
$browser->assertPathIs($this->url())
->assertSee('Dashboard');
}
public function elements()
{
return [
'@stats-widget' => '#stats-widget',
'@recent-orders' => '#recent-orders',
'@logout-button' => 'button[name=logout]',
];
}
public function viewRecentOrders(Browser $browser)
{
$browser->click('@recent-orders');
}
}
Pest PHP - Modern Testing Framework
Pest is an elegant testing framework built on top of PHPUnit with a focus on simplicity. It provides expressive syntax, powerful plugins, and better developer experience.
# Install Pest
composer require pestphp/pest --dev --with-all-dependencies
composer require pestphp/pest-plugin-laravel --dev
# Initialize Pest
php artisan pest:install
# Run Pest tests
./vendor/bin/pest
# Run with coverage
./vendor/bin/pest --coverage
# Run specific test file
./vendor/bin/pest tests/Feature/UserTest.php
<?php
// tests/Feature/UserTest.php - Pest syntax
use App\Models\User;
test('user can register', function () {
$response = $this->post('/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
$response->assertRedirect('/dashboard');
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
]);
});
it('requires email for registration', function () {
$response = $this->post('/register', [
'name' => 'John Doe',
'password' => 'password',
]);
$response->assertSessionHasErrors('email');
});
// Using higher-order tests
it('creates users')
->expect(fn() => User::factory()->create())
->toBeInstanceOf(User::class)
->toHaveKey('email');
// Dataset testing (data providers)
it('validates email format', function ($email) {
$response = $this->post('/register', [
'name' => 'John',
'email' => $email,
'password' => 'password',
]);
$response->assertSessionHasErrors('email');
})->with([
'invalid-email',
'@example.com',
'user@',
'user@.com',
]);
// Before and after hooks
beforeEach(function () {
$this->user = User::factory()->create();
});
afterEach(function () {
// Cleanup
});
// Shared setup across tests
uses(Tests\TestCase::class)->in('Feature', 'Unit');
// Grouping tests
describe('User Registration', function () {
beforeEach(function () {
// Setup for all tests in this group
});
test('successful registration', function () {
// Test implementation
});
test('registration with existing email', function () {
// Test implementation
});
});
// Custom expectations
expect($user)
->toBeInstanceOf(User::class)
->toHaveProperty('email')
->email->toBe('john@example.com');
expect($users)
->toHaveCount(3)
->each->toBeInstanceOf(User::class);
// Testing exceptions
test('throws exception for invalid data', function () {
$service = new UserService();
$service->createUser([]); // Should throw
})->throws(ValidationException::class);
// Snapshot testing
test('generates correct invoice HTML', function () {
$invoice = generateInvoice($order);
expect($invoice)->toMatchSnapshot();
});
// Parallel execution (covered in next section)
test('parallel test 1', function () {
expect(true)->toBeTrue();
})->group('parallel');
test('parallel test 2', function () {
expect(true)->toBeTrue();
})->group('parallel');
Parallel Testing
Running tests in parallel can dramatically reduce test suite execution time. Laravel supports parallel testing out of the box with the --parallel flag.
# Run tests in parallel (PHPUnit)
php artisan test --parallel
# Run with specific number of processes
php artisan test --parallel --processes=4
# Run Pest tests in parallel
./vendor/bin/pest --parallel
# Configure in phpunit.xml
<phpunit>
<extensions>
<extension class="ParaTest\Extension"/>
</extensions>
</phpunit>
<?php
// tests/Pest.php - Configure parallel execution
uses(Tests\TestCase::class)
->beforeEach(function () {
// Each parallel process gets isolated database
if (ParallelTesting::running()) {
$this->artisan('migrate:fresh');
}
})
->in('Feature');
// Database configuration for parallel testing
// config/database.php
'connections' => [
'mysql' => [
'driver' => 'mysql',
'database' => env(
'DB_DATABASE',
'laravel_' . (ParallelTesting::token() ?? '')
),
],
],
// Handling shared resources in parallel tests
use Illuminate\Support\Facades\ParallelTesting;
test('handles parallel database access', function () {
// Each process gets unique token
$token = ParallelTesting::token(); // returns 1, 2, 3, etc.
// Create unique resources
Storage::disk('testing')->put("file_{$token}.txt", 'content');
// Assertions
expect(Storage::disk('testing')->exists("file_{$token}.txt"))->toBeTrue();
});
// Setup and teardown for parallel testing
// tests/ParallelTestCase.php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\ParallelTesting;
abstract class ParallelTestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
if (ParallelTesting::running()) {
// Isolate each process
config(['cache.default' => 'array']);
config(['session.driver' => 'array']);
}
}
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
if (ParallelTesting::running()) {
// Run once per parallel process
$token = ParallelTesting::token();
exec("php artisan migrate:fresh --database=testing_{$token}");
}
}
}
// Preventing parallel execution for specific tests
test('sequential test', function () {
// Test that cannot run in parallel
})->group('sequential');
// Run sequential tests separately
./vendor/bin/pest --exclude-group=parallel
./vendor/bin/pest --group=parallel --parallel
CI/CD Integration
Integrating tests into CI/CD pipelines ensures code quality and catches bugs before deployment. Here are examples for popular CI platforms.
# GitHub Actions - .github/workflows/tests.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: mbstring, dom, fileinfo, mysql
coverage: xdebug
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Generate key
run: php artisan key:generate
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Run Migration
run: php artisan migrate --force
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
- name: Run Tests
run: php artisan test --parallel --coverage --min=80
env:
DB_CONNECTION: mysql
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
# GitLab CI - .gitlab-ci.yml
image: php:8.2
stages:
- test
cache:
paths:
- vendor/
before_script:
- apt-get update -yqq
- apt-get install -yqq git libzip-dev
- docker-php-ext-install pdo_mysql zip
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install
test:
stage: test
services:
- mysql:8.0
variables:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
DB_HOST: mysql
DB_USERNAME: root
DB_PASSWORD: password
script:
- php artisan migrate --force
- php artisan test --parallel
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
# CircleCI - .circleci/config.yml
version: 2.1
jobs:
test:
docker:
- image: cimg/php:8.2
- image: cimg/mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
steps:
- checkout
- run:
name: Install PHP Extensions
command: sudo docker-php-ext-install pdo_mysql
- restore_cache:
keys:
- v1-dependencies-{{ checksum "composer.lock" }}
- run:
name: Install Dependencies
command: composer install -n --prefer-dist
- save_cache:
key: v1-dependencies-{{ checksum "composer.lock" }}
paths:
- vendor
- run:
name: Run Tests
command: php artisan test --parallel
workflows:
version: 2
test:
jobs:
- test
Test Coverage
Measuring test coverage helps identify untested code paths and ensures comprehensive test suites.
# Generate coverage report with PHPUnit
php artisan test --coverage
# Generate HTML coverage report
php artisan test --coverage-html coverage-report
# Generate coverage with minimum threshold
php artisan test --coverage --min=80
# Pest coverage
./vendor/bin/pest --coverage --min=80
# Configure coverage in phpunit.xml
<phpunit>
<coverage>
<include>
<directory suffix=".php">./app</directory>
</include>
<exclude>
<directory>./app/Console</directory>
<file>./app/Providers/RouteServiceProvider.php</file>
</exclude>
<report>
<html outputDirectory="coverage-report"/>
<clover outputFile="coverage.xml"/>
</report>
</coverage>
</phpunit>
# Coverage interpretation
# - Lines: Percentage of code lines executed
# - Functions: Percentage of functions/methods called
# - Classes: Percentage of classes instantiated
# - Branches: Percentage of conditional branches tested
# Ideal coverage targets:
# - Critical business logic: 90-100%
# - Controllers/Services: 80-90%
# - Models: 70-80%
# - Overall project: 70-80%
# Integration with code quality tools
# composer.json
{
"scripts": {
"test": "pest",
"test:coverage": "pest --coverage --min=80",
"test:parallel": "pest --parallel",
"analyse": "phpstan analyse",
"format": "php-cs-fixer fix"
}
}
# Pre-commit hook for test coverage
# .git/hooks/pre-commit
#!/bin/sh
echo "Running tests with coverage..."
php artisan test --coverage --min=80
if [ $? -ne 0 ]; then
echo "Tests failed or coverage below threshold. Commit aborted."
exit 1
fi
Exercise 1: Browser Testing with Dusk
Create comprehensive browser tests for a shopping cart flow:
- Test adding products to cart (click "Add to Cart", verify count updates)
- Test removing items from cart (click remove icon, verify item disappears)
- Test checkout flow (fill shipping form, select payment method, confirm order)
- Use multiple browsers to test concurrent cart operations
- Take screenshots at key steps for debugging
- Test responsive design by resizing browser to mobile dimensions
Exercise 2: Migrate Tests to Pest PHP
Convert existing PHPUnit tests to Pest and add new features:
- Convert 3 existing PHPUnit test classes to Pest syntax
- Create dataset tests for validation scenarios (test 10+ invalid emails)
- Use higher-order expectations for model assertions
- Group related tests using describe() blocks
- Add snapshot testing for email templates
- Run tests and verify all pass
Exercise 3: CI/CD Pipeline with Coverage
Set up a complete CI/CD pipeline with test coverage reporting:
- Create GitHub Actions workflow that runs tests on push and PR
- Configure parallel testing with 4 processes
- Set up MySQL and Redis services in the workflow
- Generate test coverage report and enforce 80% minimum
- Upload coverage to Codecov or similar service
- Add status badge to README showing test and coverage status
- Test the workflow by pushing a commit
Summary
In this lesson, you've mastered advanced testing techniques for comprehensive quality assurance. You learned browser automation with Laravel Dusk for end-to-end testing of JavaScript-heavy applications and complex user interactions, explored Pest PHP for elegant, modern testing syntax with datasets and higher-order expectations, implemented parallel testing to dramatically reduce test suite execution time, integrated tests into CI/CD pipelines with GitHub Actions, GitLab CI, and CircleCI, and measured test coverage to identify untested code paths and ensure comprehensive test suites. These advanced testing strategies enable you to build robust, well-tested Laravel applications with confidence in code quality and reliability.