Advanced Laravel

Advanced Testing: Browser & Integration Tests

18 min Lesson 20 of 40

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');
    }
}
Best Practice: Use Dusk for critical user flows (registration, checkout, payment) and features that heavily rely on JavaScript. For API testing and simple HTTP interactions, use feature tests instead—they're much faster.

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');
Migration Tip: You can gradually adopt Pest alongside PHPUnit. Pest tests and PHPUnit tests can coexist in the same project. Start with new tests in Pest while keeping existing PHPUnit tests.

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
Warning: Parallel testing requires careful handling of shared resources (files, cache, external APIs). Always ensure tests are isolated and don't depend on execution order or shared state.

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:

  1. Test adding products to cart (click "Add to Cart", verify count updates)
  2. Test removing items from cart (click remove icon, verify item disappears)
  3. Test checkout flow (fill shipping form, select payment method, confirm order)
  4. Use multiple browsers to test concurrent cart operations
  5. Take screenshots at key steps for debugging
  6. 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:

  1. Convert 3 existing PHPUnit test classes to Pest syntax
  2. Create dataset tests for validation scenarios (test 10+ invalid emails)
  3. Use higher-order expectations for model assertions
  4. Group related tests using describe() blocks
  5. Add snapshot testing for email templates
  6. Run tests and verify all pass

Exercise 3: CI/CD Pipeline with Coverage

Set up a complete CI/CD pipeline with test coverage reporting:

  1. Create GitHub Actions workflow that runs tests on push and PR
  2. Configure parallel testing with 4 processes
  3. Set up MySQL and Redis services in the workflow
  4. Generate test coverage report and enforce 80% minimum
  5. Upload coverage to Codecov or similar service
  6. Add status badge to README showing test and coverage status
  7. 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.