Browser Testing

Measure real user experience under load with Chromium automation

Overview

Fusillade includes headless browser automation via Chromium for measuring real user experience while your backend is under load. Unlike HTTP tests which measure API response times, browser tests measure what users actually experience: page load times, DOM rendering, JavaScript execution, and visual completeness.

Hybrid Testing Pattern: Use HTTP workers to generate backend load while a small number of browser instances measure frontend performance.

Key Insight: API response times don't tell the whole story. A 50ms API response can still result in a 3-second page load due to rendering, JS execution, and resource loading.

Resource Note:

Each browser instance requires ~100-200MB of memory. Use 1-5 browser workers alongside many HTTP workers.

Performance Metrics

The key feature for load testing is page.metrics() which returns browser performance timing data.

page.metrics()

Returns performance timing metrics object

Metrics Object Properties

navigationStart - When navigation began (timestamp)

domInteractive - DOM is ready for interaction

domComplete - DOM and all resources loaded

loadEventEnd - Load event handler completed

Key Performance Indicators

Time to Interactive (TTI) = domInteractive - navigationStart

DOM Complete = domComplete - navigationStart

Full Page Load = loadEventEnd - navigationStart

# Measuring Page Performance

export default function() {
    const browser = chromium.launch();
    const page = browser.newPage();

    page.goto('https://example.com/dashboard');

    // Get performance timing metrics
    const perf = page.metrics();

    // Calculate key metrics
    const tti = perf.domInteractive - perf.navigationStart;
    const domComplete = perf.domComplete - perf.navigationStart;
    const fullLoad = perf.loadEventEnd - perf.navigationStart;

    print(`Time to Interactive: ${tti}ms`);
    print(`DOM Complete: ${domComplete}ms`);
    print(`Full Page Load: ${fullLoad}ms`);

    // Record as custom metrics for thresholds
    metrics.histogramAdd('browser_tti', tti);
    metrics.histogramAdd('browser_dom_complete', domComplete);
    metrics.histogramAdd('browser_full_load', fullLoad);

    browser.close();
}

Hybrid Load Testing

The most powerful use of browser testing is combining it with HTTP load generation. HTTP workers stress your backend while browser workers measure the actual user experience.

# Hybrid Test: HTTP Load + Browser Monitoring

export const options = {
    scenarios: {
        // Generate API load with many HTTP workers
        api_load: {
            executor: 'constant-workers',
            workers: 100,
            duration: '5m',
            exec: 'apiLoad',
        },
        // Measure user experience with few browser workers
        browser_monitor: {
            executor: 'constant-workers',
            workers: 2,
            duration: '5m',
            exec: 'browserMonitor',
        },
    },
    thresholds: {
        // HTTP thresholds
        'http_req_duration': ['p95 < 500'],
        // Browser experience thresholds
        'browser_tti': ['p95 < 2000'],
        'browser_full_load': ['p95 < 5000'],
    },
};

// HTTP load generation
export function apiLoad() {
    http.get('https://api.example.com/data');
    http.post('https://api.example.com/actions', JSON.stringify({ action: 'test' }));
    sleep(0.1);
}

// Browser experience measurement
export function browserMonitor() {
    const browser = chromium.launch();
    const page = browser.newPage();

    page.goto('https://example.com/dashboard');

    const perf = page.metrics();
    const tti = perf.domInteractive - perf.navigationStart;
    const fullLoad = perf.loadEventEnd - perf.navigationStart;

    metrics.histogramAdd('browser_tti', tti);
    metrics.histogramAdd('browser_full_load', fullLoad);

    check(page, {
        'page loads under 5s': () => fullLoad < 5000,
        'interactive under 2s': () => tti < 2000,
    });

    browser.close();
    sleep(5); // Check every 5 seconds
}

Browser API

chromium.launch()

Launch a new headless Chromium browser instance

browser.newPage()

Open a new browser page/tab

browser.close()

Close the browser and release resources

Page API

page.goto(url)

Navigate to URL and wait for load

page.content()

Get page HTML content as a string

page.title()

Get the page title (via document.title)

page.url()

Get the current page URL

page.click(selector)

Click an element matching the CSS selector

page.type(selector, text)

Type text into an input element (appends to existing value)

page.fill(selector, text)

Clear input value and type new text

page.evaluate(script)

Execute JavaScript in page context and return the result

page.metrics()

Get performance timing metrics (see Performance Metrics above)

page.screenshot()

Capture a PNG screenshot, returns raw PNG bytes

page.waitForSelector(selector)

Wait for an element matching the CSS selector to appear in the DOM

page.waitForNavigation()

Wait for the current page navigation to complete

page.waitForTimeout(ms)

Wait for specified number of milliseconds

page.setContent(html)

Replace page content with given HTML string

page.focus(selector)

Focus the element matching the CSS selector

page.select(selector, values)

Select options in a <select> element by value array

page.getCookies()

Get array of cookies [{name, value}, ...]

page.setCookie(cookie)

Set a cookie {name, value, domain?, path?, secure?, maxAge?}

page.deleteCookie(name)

Delete a cookie by name

page.queryAll(selector)

Query all matching elements [{tag, text, id, className}, ...]

page.waitForResponse(urlPattern, [timeoutMs])

Wait for network response matching URL pattern (default 30s)

page.close()

Close the page/tab and release resources

# Page API Usage

export default function() {
    const browser = chromium.launch();
    const page = browser.newPage();

    // Navigate to page
    page.goto('https://example.com/login');

    // Get page info
    print(`Title: ${page.title()}`);
    print(`URL: ${page.url()}`);

    // Type into form fields (appends to existing value)
    page.type('#username', 'testuser');

    // Fill clears the input first, then types
    page.fill('#password', 'secret123');

    // Click submit button
    page.click('button[type="submit"]');

    // Wait for navigation to complete after form submission
    page.waitForNavigation();

    // Wait for a specific element to appear (useful for SPAs)
    page.waitForSelector('.dashboard-content');

    // Verify we landed on the right page
    check(null, {
        'redirected to dashboard': () => page.url().includes('/dashboard'),
        'welcome message visible': () => page.content().includes('Welcome'),
    });

    // Execute JavaScript in the page context
    const title = page.evaluate('document.title');
    print(`Page title: ${title}`);

    // Capture screenshot (returns PNG bytes)
    const png = page.screenshot();

    page.close();
    browser.close();
}

User Flow Testing

Test complete user journeys while measuring performance at each step.

export default function() {
    const browser = chromium.launch();
    const page = browser.newPage();

    // Step 1: Load login page
    page.goto('https://example.com/login');
    let perf = page.metrics();
    metrics.histogramAdd('login_page_load', perf.loadEventEnd - perf.navigationStart);

    // Step 2: Fill and submit login form
    page.fill('#username', 'testuser');
    page.fill('#password', 'secret123');

    const loginStart = Date.now();
    page.click('button[type="submit"]');
    page.waitForNavigation();
    metrics.histogramAdd('login_submit_time', Date.now() - loginStart);

    // Step 3: Verify dashboard loaded
    perf = page.metrics();
    metrics.histogramAdd('dashboard_load', perf.loadEventEnd - perf.navigationStart);

    check(null, {
        'logged in successfully': () => page.url().includes('/dashboard'),
        'welcome message visible': () => page.content().includes('Welcome'),
    });

    // Step 4: Navigate to a data-heavy page
    const dataStart = Date.now();
    page.click('a[href="/reports"]');
    page.waitForSelector('.report-table');
    metrics.histogramAdd('reports_load', Date.now() - dataStart);

    page.close();
    browser.close();
}

JavaScript Evaluation

Execute JavaScript in the page context to extract data or measure client-side performance.

export default function() {
    const browser = chromium.launch();
    const page = browser.newPage();

    page.goto('https://example.com/app');

    // Get browser-side performance data
    const perfData = page.evaluate(`
        const timing = performance.timing;
        const paint = performance.getEntriesByType('paint');
        return {
            // Navigation timing
            dns: timing.domainLookupEnd - timing.domainLookupStart,
            tcp: timing.connectEnd - timing.connectStart,
            ttfb: timing.responseStart - timing.requestStart,
            download: timing.responseEnd - timing.responseStart,
            domParsing: timing.domInteractive - timing.responseEnd,

            // Paint timing
            firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0,
            firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,

            // Resource count
            resourceCount: performance.getEntriesByType('resource').length,
        };
    `);

    print(`First Contentful Paint: ${perfData.firstContentfulPaint}ms`);
    print(`Resources loaded: ${perfData.resourceCount}`);

    metrics.histogramAdd('browser_fcp', perfData.firstContentfulPaint);
    metrics.histogramAdd('browser_ttfb', perfData.ttfb);

    browser.close();
}

Screenshots on Failure

Capture screenshots when checks fail to help debug issues. The page.screenshot() method returns raw PNG bytes.

export default function() {
    const browser = chromium.launch();
    const page = browser.newPage();

    page.goto('https://example.com/checkout');

    const perf = page.metrics();
    const loadTime = perf.loadEventEnd - perf.navigationStart;

    // If page is slow, capture screenshot for debugging
    if (loadTime > 5000) {
        const png = page.screenshot();
        print(`WARNING: Slow page load (${loadTime}ms) - screenshot captured (${png.length} bytes)`);
    }

    // Check page content
    const html = page.content();
    const checksPassed = check(null, {
        'checkout form visible': () => html.includes('checkout-form'),
        'price displayed': () => html.includes('$'),
    });

    if (!checksPassed) {
        const png = page.screenshot();
        print(`Check failed - screenshot captured (${png.length} bytes)`);
    }

    browser.close();
}

Best Practices

1. Use few browser workers (1-5) alongside many HTTP workers

2. Focus on page.metrics() for performance data

3. Record metrics with metrics.histogramAdd() for thresholds

4. Use page.waitForSelector() instead of fixed delays for dynamic content

5. Use page.waitForNavigation() after clicks that trigger page loads

6. Always close pages and browsers to prevent memory leaks

7. Capture screenshots on failures for debugging

8. Use page.fill() for form inputs (clears first) and page.type() to append

Browser Performance Thresholds

export const options = {
    workers: 2,
    duration: '5m',
    thresholds: {
        // Time to Interactive under 2 seconds
        'browser_tti': ['p95 < 2000'],

        // Full page load under 5 seconds
        'browser_full_load': ['p95 < 5000'],

        // First Contentful Paint under 1.5 seconds
        'browser_fcp': ['p95 < 1500'],

        // All checks must pass
        'checks': ['rate > 0.99'],
    },
};

export default function() {
    const browser = chromium.launch();
    const page = browser.newPage();

    page.goto('https://example.com');
    const perf = page.metrics();

    metrics.histogramAdd('browser_tti', perf.domInteractive - perf.navigationStart);
    metrics.histogramAdd('browser_full_load', perf.loadEventEnd - perf.navigationStart);

    browser.close();
}