Running Laravel Dusk E2E tests with Selenium in GitLab CI

5 min read

Laravel Dusk E2E

End-to-end (E2E) testing is crucial for ensuring application quality by simulating real user scenarios. Laravel Dusk provides an elegant API for browser automation and testing. However, running these tests in a CI/CD environment like GitLab can be tricky.

This article will guide you through setting up a GitLab CI job to run your Laravel Dusk tests using a standalone Selenium service, ensuring your application works as expected from the user’s perspective.

Environment Configuration

First, we need to configure our Laravel application to communicate with the services inside the GitLab CI environment. We’ll use a dedicated .env file for this, which will be copied in our CI job. Let’s call it .env.ci.dusk.chrome.

# .env.ci.dusk.chrome

# ...

APP_URL=http://build:8000

DUSK_BROWSER=chrome
DUSK_DRIVER_URL=http://selenium:4444/wd/hub

# ...

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=dusk_db
DB_USERNAME=root
DB_PASSWORD=root

Key points here:

  • APP_URL: Points to http://build:8000. In GitLab CI, build is the default hostname of the job container. We’ll run php artisan serve on port 8000.
  • DUSK_DRIVER_URL: This tells Dusk to connect to the Selenium service, which we’ll name selenium.
  • DB_HOST: This is set to mysql, the hostname of our database service in GitLab CI.

GitLab CI Job (.gitlab-ci.yml)

Now, let’s define the GitLab CI job that will execute our Dusk tests.

# .gitlab-ci.yml

stages:
  - test
  # ...

variables:
  FF_NETWORK_PER_BUILD: 1 # Enables network communication between job and services

# ...

dusk:
  stage: test
  image: lorisleiva/laravel-docker:8.4
  services:
    - mysql:latest
    - name: selenium/standalone-chromium:latest
      alias: selenium
  variables:
    MYSQL_DATABASE: dusk_db
    MYSQL_ROOT_PASSWORD: root
  before_script:
    - echo 'memory_limit = 512M' >> /usr/local/etc/php/conf.d/docker-php-memory-limit.ini
    - cp .env.ci.dusk.chrome .env
  script:
    - composer install --prefer-dist --no-ansi --no-interaction --no-progress --no-scripts --ignore-platform-reqs
    - php artisan key:generate --force
    - php artisan migrate --force
    - npm install
    - npm run build
    - php artisan serve --host=0.0.0.0 --port=8000 &
    - |
      for i in {1..30}; do
        if curl -s http://build:8000 > /dev/null; then
          echo "Server is up and running!"
          break
        fi
        echo "Waiting for server to start... ($i/30)"
        sleep 2
      done
    - php artisan dusk --no-ansi
  artifacts:
    when: always
    paths:
      - tests/Browser/screenshots
      - tests/Browser/console
      - storage/logs
    expire_in: 7 day
  # ... (cache and rules omitted for brevity)

Let’s break down this job:

  • FF_NETWORK_PER_BUILD: 1: This is a crucial GitLab feature flag that creates a dedicated network for each job, allowing the job container and its services to communicate via hostnames.
  • image: We use lorisleiva/laravel-docker:8.4, a convenient image containing PHP, Composer, Node, and other necessary tools.
  • services: We define two services. mysql:latest for our database, and selenium/standalone-chromium:latest for running Chrome. We give the Selenium service an alias: selenium so we can reach it at http://selenium:4444.
  • before_script: We copy our CI-specific .env file.
  • script:
    1. We install dependencies (composer and npm).
    2. We set up the application (key:generate, migrate).
    3. We build frontend assets (npm run build).
    4. php artisan serve --host=0.0.0.0 --port=8000 & starts the Laravel development server in the background. --host=0.0.0.0 is essential to make it accessible from outside the container.
    5. The for loop is a simple health check. It waits for the application server to become responsive before proceeding.
    6. php artisan dusk finally runs our tests.
  • artifacts: We save screenshots, console logs, and application logs from failed tests for debugging.

Configuring DuskTestCase.php

To make Dusk connect to the remote Selenium driver instead of a local ChromeDriver, we need to modify tests/DuskTestCase.php. This setup also allows for running tests with Firefox by changing the DUSK_BROWSER environment variable.

// tests/DuskTestCase.php
<?php

namespace Tests;

use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Firefox\FirefoxOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;

abstract class DuskTestCase extends BaseTestCase
{
    /**
     * Prepare for Dusk test execution.
     */
    #[BeforeClass]
    public static function prepare(): void
    {
        // We don't need to start a local chromedriver when using a remote Selenium service.
        // The default behavior is commented out.
        // if (! static::runningInSail() || $isCI) {
        //     static::startChromeDriver(['--port=9515']);
        // }
    }

    /**
     * Create the RemoteWebDriver instance.
     */
    protected function driver(): RemoteWebDriver
    {
        $browser = $_ENV['DUSK_BROWSER'] ?? env('DUSK_BROWSER', 'chrome');
        $seleniumUrl = $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515';

        if ($browser === 'firefox') {
            $ffArgs = collect([])
                ->unless($this->hasHeadlessDisabled(), fn ($items) => $items->merge(['-headless']))
                ->all();

            $options = (new FirefoxOptions())->addArguments($ffArgs);

            $capabilities = DesiredCapabilities::firefox()
                ->setCapability(FirefoxOptions::CAPABILITY, $options)
                ->setCapability('acceptInsecureCerts', true);

            return RemoteWebDriver::create($seleniumUrl, $capabilities, 60000, 120000);
        }

        $chArgs = collect([
            $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
            '--disable-search-engine-choice-screen',
            '--disable-smooth-scrolling',
            '--ignore-certificate-errors',
            '--allow-insecure-localhost',
            '--disable-dev-shm-usage',
            '--no-sandbox',
        ])->unless($this->hasHeadlessDisabled(), fn ($items) => $items->merge([
            '--headless=new',
            '--window-size=1920,1080',
        ]))->all();

        $options = (new ChromeOptions())->addArguments($chArgs);

        $capabilities = DesiredCapabilities::chrome()
            ->setCapability(ChromeOptions::CAPABILITY, $options)
            ->setCapability('acceptInsecureCerts', true);

        return RemoteWebDriver::create($seleniumUrl, $capabilities, 60000, 120000);
    }
}

The key changes are:

  • The prepare method is emptied to prevent Dusk from trying to start its own ChromeDriver.
  • The driver method is overridden to create a RemoteWebDriver instance. It reads the DUSK_DRIVER_URL from the environment and connects to our Selenium service.
  • It includes several useful Chrome arguments for running in a containerized environment, like --no-sandbox and --disable-dev-shm-usage.

Example Dusk Test

Here is a simple login test to demonstrate the setup. It uses Dusk’s Page Objects and assertions to verify the login functionality.

// tests/Browser/LoginTest.php
<?php

namespace Tests\Browser;

use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTruncation;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\Browser\Pages\LoginPage;
use Tests\DuskTestCase;

class LoginTest extends DuskTestCase
{
    use DatabaseTruncation;
    public function test_users_can_login(): void
    {
        $this->browse(function (Browser $browser) {
            $user = User::factory()->create([
                'password' => Hash::make('password'),
            ]);

            $browser->visit(new LoginPage())
                ->waitFor('@login-page', 10)
                ->type('@email', $user->email)
                ->type('@password', 'password')
                ->click('@submit')
                ->waitFor('@dashboard-page', 10)
                ->assertPathIs('/dashboard')
                ->assertAuthenticated();
        });
    }
}

With this configuration, your Dusk tests will run seamlessly in your GitLab CI pipeline, providing you with fast and reliable feedback on your application’s E2E behavior.

  • #laravel
  • #dusk
  • #selenium
  • #gitlab
  • #ci-cd
  • #e2e-testing