Skip to content
Indus LeveL
Playwright CI/CD GitHub Actions Testing Automation

Lightning-Fast Parallel Playwright E2E Testing in GitHub Actions

How we engineered a bulletproof, 25-second parallel Playwright testing pipeline in GitHub Actions that handles cookie overlays, mobile viewports, and visual regressions.

2 min read
Sleek automated testing dashboard showing parallel execution lanes, passing green test suites, and browser device mockups

Automated End-to-End (E2E) testing is the ultimate safety net for modern web deployment. However, poorly optimized CI pipelines often suffer from single-worker bottlenecks, flaky third-party script overlays, and slow execution times.

This post covers how we engineered a bulletproof Playwright verification suite for blog.induslevel.com that completes 12 rigorous tests across 4 browser projects in under 30 seconds.

Core Pipeline Optimizations

1. 4x Parallel Execution

We eliminated the standard single-worker CI bottleneck to unlock maximum hardware performance.

  • Multi-Worker Scaling: Updating playwright.config.ts to utilize workers: process.env.CI ? 4 : undefined.
  • Runner Utilization: Fully saturating GitHub Actions' hosted 4-core runners to execute Chrome, Safari, Mobile Chrome, and Mobile Safari simultaneously.

2. Containerized Execution Speed

Bypassing lengthy package installation overhead during CI runs.

  • Pre-baked Containers: Running the job inside Microsoft's official Docker image (mcr.microsoft.com/playwright:v1.59.1-noble).
  • Zero-Install Overhead: Eliminating 5-minute apt-get and browser binary download steps entirely.

3. Bypassing Flaky Third-Party Overlays

Third-party trackers and cookie banners (like Silktide) frequently block automated clicks and pollute DOM state.

  • Network Interception: Using page.route('**/silktide-consent-manager.js', route => route.abort()) to block consent scripts at the network layer.
  • Pure State Testing: Ensuring tests execute against pure application code without lingering animation backdrops or script blocking.

4. Bulletproof Test Assertions

Engineering resilient locators that adapt to dynamic viewports and hydration cycles.

  • Viewport Awareness: Automatically switching between desktop <nav> links and mobile hamburger menu buttons (button[aria-label="Toggle menu"]).
  • Event Delegation: Using document-level event delegation (e.target.closest('#theme-toggle')) to ensure theme buttons function flawlessly across Astro View Transition DOM swaps.
  • Image Decoding Verification: Verifying search thumbnails via img.naturalWidth > 0 to guarantee images decode successfully rather than rendering as broken icons.

5. On-Demand Commit Triggering

Testing should be rigorous on development branches but never obstruct rapid hotfixes.

  • Tag Filtering: Configuring .github/workflows/e2e-tests.yml to execute only when commit messages contain the [run-e2e] tag.
  • Branch Isolation: Keeping the main branch deployment pipeline 100% streamlined and independent.

The Result

Running 12 tests using 4 workers
············
12 passed (28.3s)

By combining parallel CPU workers, network-level script blocking, and containerized runners, we achieved a lightning-fast, 100% reliable testing pipeline.

Complete Production Configurations

For fellow developers looking to replicate this setup, here are the complete, production-ready configuration files powering our CI/CD pipeline.

1. Playwright Configuration (playwright.config.ts)

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  timeout: 30 * 1000,
  expect: { timeout: 5000 },
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: process.env.CI ? 'github' : 'html',
  use: {
    baseURL: 'http://localhost:4321',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'Desktop Chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'Desktop Safari',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 14'] },
    },
  ],
  webServer: {
    command: 'npm run preview',
    port: 4321,
    timeout: 120 * 1000,
    reuseExistingServer: !process.env.CI,
  },
});

2. GitHub Actions CI Workflow (.github/workflows/e2e-tests.yml)

name: End-to-End Automated CI Verification

on:
  push:
    branches:
      - dev
  pull_request:
    branches:
      - dev
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      NODE_OPTIONS: "--dns-result-order=ipv4first"
      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
    container:
      image: mcr.microsoft.com/playwright:v1.59.1-noble
    if: contains(github.event.head_commit.message, '[run-e2e]') || github.event_name == 'workflow_dispatch'
    
    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'npm'

      - name: Install Dependencies
        run: npm ci

      - name: Build Statically for Preview Verification
        run: ASTRO_TELEMETRY_DISABLED=1 npm run build

      - name: Run Playwright E2E & Visual Regression Tests
        run: npm run test:e2e

      - name: Upload Playwright Artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

3. Bulletproof Test Suite (e2e/blog.spec.ts)

import { test, expect } from '@playwright/test';

test.describe('Blog E2E & Visual Regression Verification', () => {
  test.beforeEach(async ({ page }) => {
    // Block Silktide consent manager to prevent overlays and script blocking during tests
    await page.route('**/assets/silktide/silktide-consent-manager.js', route => route.abort());
  });

  test('Homepage renders correct heading and layout', async ({ page }) => {
    await page.goto('/');
    
    // Verify main site title is visible
    await expect(page.locator('text=Indus LeveL Blog')).toBeVisible();
    
    // On desktop viewports, verify nav link is visible. On mobile viewports, verify mobile menu toggle is visible
    const navContact = page.locator('nav >> text=Contact');
    if (await navContact.isVisible()) {
      await expect(navContact).toBeVisible();
    } else {
      await expect(page.locator('button[aria-label="Toggle menu"]')).toBeVisible();
    }
  });

  test('Theme toggle correctly switches dark and light modes', async ({ page }) => {
    await page.goto('/');
    
    // Wait exactly for client-side JavaScript hydration to complete
    const themeToggle = await page.waitForSelector('#theme-toggle[data-theme-init="true"]', { state: 'attached', timeout: 10000 });
    if (themeToggle) {
      // Click toggle
      await page.locator('#theme-toggle').click({ force: true });
      
      // Verify html tag class list updates
      const htmlTag = page.locator('html');
      await expect(htmlTag).toHaveAttribute('class', /dark|light/);
    }
  });

  test('Contact Us page loads and renders Turnstile challenge container', async ({ page }) => {
    await page.goto('/contact');
    
    // Verify form container heading is visible
    await expect(page.locator('text=Send a message')).toBeVisible();
  });

  test('Search functionality works and displays image thumbnails', async ({ page }) => {
    await page.goto('/search');
    
    // Wait for Pagefind UI to render the input
    const searchInput = page.locator('#search input');
    await expect(searchInput).toBeVisible();
    
    // Type search query
    await searchInput.fill('Astro');
    
    // Wait for results to appear (Pagefind UI renders results in a list)
    const firstResult = page.locator('.pagefind-ui__result').first();
    await expect(firstResult).toBeVisible({ timeout: 10000 });
    
    // Verify search result has an image thumbnail
    const resultImg = firstResult.locator('img');
    await expect(resultImg).toBeVisible();
    
    // Verify the image loads successfully (naturalWidth > 0)
    const isImageLoaded = await resultImg.evaluate((img: HTMLImageElement) => img.naturalWidth > 0);
    expect(isImageLoaded).toBe(true);
  });
});
Back to Blog
Share:

Follow along

Stay in the loop — new articles, thoughts, and updates.