Skip to main content

Browser Testing Best Practices

This guide presents recommended practices and patterns for working with the k6 browser module to create reliable, maintainable tests.

Always Close Pages

Always call page.close() at the end of your browser tests to ensure accurate metric collection and proper resource cleanup.

Why Close Pages?

  • Ensures accurate and complete metric collection
  • Cleans up event listeners to prevent resource leaks
  • Simplifies test teardown for improved reliability
  • Allows proper calculation of Web Vital metrics

Implementation Pattern

Use a try/finally block to guarantee page closure:
import { browser } from 'k6/browser';
import { check } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'https://quickpizza.grafana.com/';

export const options = {
  scenarios: {
    ui: {
      executor: 'shared-iterations',
      options: {
        browser: {
          type: 'chromium',
        },
      },
    },
  },
};

export default async function () {
  let checkData;
  const page = await browser.newPage();
  
  try {
    await page.goto(BASE_URL);
    
    checkData = await page.locator('h1').textContent();
    check(page, {
      header: checkData == 'Looking to break out of your pizza routine?',
    });

    await page.locator('//button[. = "Pizza, Please!"]').click();
    await page.waitForTimeout(500);
    
    await page.screenshot({ path: 'screenshot.png' });
    
    checkData = await page.locator('div#recommendations').textContent();
    check(page, {
      recommendation: checkData != '',
    });
  } finally {
    // Always close the page at the end
    await page.close();
  }
}

Choose Robust Selectors

Selectors should not be tightly coupled to styling or implementation details. Use user-facing attributes that are unlikely to change.

Selector Priority Guide

1
Best: User-Facing Attributes
2
Use ARIA labels and custom data attributes:
3
// Excellent - ARIA labels rarely change
page.locator('[aria-label="Login"]')

// Excellent - custom data attributes
page.locator('[data-test="login"]')
page.locator('[data-testid="submit-button"]')
4
Good: Text Content
5
Use XPath for text-based selection:
6
// Good - text content is stable
page.locator('//button[text()="Submit"]')
page.locator('//a[contains(text(), "Learn More")]')
7
Use Sparingly: IDs
8
Only use IDs if they don’t change:
9
// OK if ID is stable
page.locator('#login-btn')
10
Avoid: Class Names
11
Class names can be duplicated and change frequently:
12
// Avoid - class names change often
page.locator('.login-btn')
13
Never: Generic Elements or Absolute Paths
14
These are brittle and provide no context:
15
// Never - no context
page.locator('button')

// Never - tightly coupled to DOM structure
page.locator('/html[1]/body[1]/main[1]/div[2]/button[1]')

Selector Comparison Table

SelectorRecommendedReason
[aria-label="Login"]✅ BestUser-facing, rarely changes
[data-test="login"]✅ BestDesigned for testing
//button[text()="Submit"]✅ GoodText content is stable
#login-btn⚠️ OKOnly if ID is stable
.login-btn⚠️ SparinglyCan be duplicated
button❌ AvoidNo context
/html[1]/body[1]/main[1]❌ NeverBrittle, tightly coupled

Hybrid Performance Testing

Combine a small number of browser VUs with many protocol-level VUs for efficient, comprehensive testing.

Why Hybrid Testing?

  • Less resource-intensive than pure browser testing
  • Tests real user flows on frontend while generating higher load on backend
  • Measures both backend and frontend performance in one execution
  • Increases collaboration between teams
Keep in mind that browser VUs have additional performance overhead. Resource usage depends on the system under test.

Implementation Example

import { browser } from 'k6/browser';
import { check } from 'k6';
import http from 'k6/http';

const BASE_URL = __ENV.BASE_URL || 'https://quickpizza.grafana.com';

export const options = {
  scenarios: {
    // Protocol-level load test (90% of traffic)
    load: {
      exec: 'getPizza',
      executor: 'ramping-vus',
      stages: [
        { duration: '5s', target: 5 },
        { duration: '10s', target: 5 },
        { duration: '5s', target: 0 },
      ],
      startTime: '10s',
    },
    // Browser-level test (10% of traffic)
    browser: {
      exec: 'checkFrontend',
      executor: 'constant-vus',
      vus: 1,
      duration: '30s',
      options: {
        browser: {
          type: 'chromium',
        },
      },
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<500', 'p(99)<1000'],
    browser_web_vital_fcp: ['p(95) < 1000'],
    browser_web_vital_lcp: ['p(95) < 2000'],
  },
};

export function getPizza() {
  const restrictions = {
    maxCaloriesPerSlice: 500,
    mustBeVegetarian: false,
    excludedIngredients: ['pepperoni'],
    excludedTools: ['knife'],
    maxNumberOfToppings: 6,
    minNumberOfToppings: 2,
  };

  const res = http.post(`${BASE_URL}/api/pizza`, JSON.stringify(restrictions), {
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'token abcdef0123456789',
    },
  });

  check(res, {
    'status is 200': (res) => res.status === 200,
  });
}

export async function checkFrontend() {
  const page = await browser.newPage();

  try {
    await page.goto(BASE_URL);

    check(page.locator('h1'), {
      header: async (lo) =>
        (await lo.textContent()) == 'Looking to break out of your pizza routine?',
    });

    await page.locator('//button[. = "Pizza, Please!"]').click();
    await page.waitForTimeout(500);
    
    await page.screenshot({ path: `screenshots/${__ITER}.png` });

    check(page.locator('div#recommendations'), {
      recommendation: async (lo) => (await lo.textContent()) != '',
    });
  } finally {
    await page.close();
  }
}

Hybrid Testing Recommendations

Start small - Begin with 10% or fewer browser VUs to monitor user experience while 90% of traffic comes from protocol level.
Experiment with load patterns - Combine browser tests with different load testing types to understand the impact on end-user experience.
Focus on high-risk journeys - Identify critical user journeys first to monitor Web Vitals during high traffic or service faults.

Use Page Object Model

For large test suites, implement the page object model pattern to improve maintainability and reduce code duplication.

Benefits

  • Encapsulates UI structure details in one place
  • Makes tests easier to read and maintain
  • Reduces code duplication across tests
  • Isolates changes to specific pages

Example Implementation

Create a page object class:
// pages/homepage.js
import { bookingData } from '../data/booking-data.js';

export class Homepage {
  constructor(page) {
    this.page = page;
    this.nameField = page.locator('[data-testid="ContactName"]');
    this.emailField = page.locator('[data-testid="ContactEmail"]');
    this.phoneField = page.locator('[data-testid="ContactPhone"]');
    this.subjectField = page.locator('[data-testid="ContactSubject"]');
    this.descField = page.locator('[data-testid="ContactDescription"]');
    this.submitButton = page.locator('#submitContact');
    this.verificationMessage = page.locator('.row.contact h2');
  }

  async goto() {
    await this.page.goto('https://myexamplewebsite/');
  }

  async submitForm() {
    const { name, email, phone, subject, description } = bookingData;

    await this.nameField.type(name);
    await this.emailField.type(email);
    await this.phoneField.type(phone);
    await this.subjectField.type(subject);
    await this.descField.type(description);
    await this.submitButton.click();
  }

  async getVerificationMessage() {
    return await this.verificationMessage.innerText();
  }
}
Use the page object in your test:
import { browser } from 'k6/browser';
import { Homepage } from '../pages/homepage.js';
import { bookingData } from '../data/booking-data.js';

export default async function () {
  const page = await browser.newPage();

  const { name } = bookingData;

  const homepage = new Homepage(page);
  await homepage.goto();
  await homepage.submitForm();

  const message = await homepage.getVerificationMessage();
  console.log(`Verification: ${message}`);

  await page.close();
}

Set Performance Thresholds

Define thresholds for browser metrics to catch performance regressions early.

Web Vitals Thresholds

export const options = {
  scenarios: {
    ui: {
      executor: 'shared-iterations',
      options: {
        browser: {
          type: 'chromium',
        },
      },
    },
  },
  thresholds: {
    // Core Web Vitals
    'browser_web_vital_lcp': ['p(90) < 1000'],  // Largest Contentful Paint
    'browser_web_vital_fcp': ['p(95) < 800'],   // First Contentful Paint
    'browser_web_vital_inp': ['p(90) < 100'],   // Interaction to Next Paint
    'browser_web_vital_cls': ['p(95) < 0.1'],   // Cumulative Layout Shift
    
    // Other metrics
    'browser_http_req_duration': ['p(95) < 500'],
    'browser_http_req_failed': ['rate < 0.01'],
    'checks': ['rate==1.0'],
  },
};

URL-Specific Thresholds

Set different thresholds for different pages:
export const options = {
  thresholds: {
    'browser_web_vital_lcp': ['p(90) < 1000'],
    'browser_web_vital_inp{url:https://test.k6.io/}': ['p(90) < 80'],
    'browser_web_vital_inp{url:https://test.k6.io/my_messages.php}': ['p(90) < 100'],
  },
};

Handle Dynamic Elements

Use waitFor() to handle elements that appear dynamically:
// Wait for element to appear
const dynamicButton = page.locator('[data-test="load-more"]');
await dynamicButton.waitFor();
await dynamicButton.click();

// Wait for element to be visible
const modal = page.locator('.modal');
await modal.waitFor({ state: 'visible' });

// Wait for element to be hidden
const loadingSpinner = page.locator('.loading-spinner');
await loadingSpinner.waitFor({ state: 'hidden' });

Measure Custom Metrics

Use the Performance API with k6 Trends to track custom timing metrics:
import { browser } from 'k6/browser';
import { Trend } from 'k6/metrics';

const myTrend = new Trend('total_action_time', true);

export const options = {
  scenarios: {
    ui: {
      executor: 'shared-iterations',
      options: {
        browser: {
          type: 'chromium',
        },
      },
    },
  },
};

export default async function () {
  const page = await browser.newPage();

  try {
    await page.goto('https://test.k6.io/browser.php');
    
    // Mark start time
    await page.evaluate(() => window.performance.mark('page-visit'));

    // Perform actions
    await page.locator('#checkbox1').check();
    await page.locator('#counter-button').click();
    await page.locator('#text1').fill('This is a test');

    // Mark end time
    await page.evaluate(() => window.performance.mark('action-completed'));

    // Measure duration
    await page.evaluate(() =>
      window.performance.measure('total-action-time', 'page-visit', 'action-completed')
    );

    const totalActionTime = await page.evaluate(
      () =>
        JSON.parse(JSON.stringify(window.performance.getEntriesByName('total-action-time')))[0]
          .duration
    );

    myTrend.add(totalActionTime);
  } finally {
    await page.close();
  }
}

Environment Variables

Use environment variables to make tests reusable across environments:
const BASE_URL = __ENV.BASE_URL || 'https://test.k6.io';
const USERNAME = __ENV.USERNAME || 'admin';
const PASSWORD = __ENV.PASSWORD || 'password123';

export default async function () {
  const page = await browser.newPage();

  try {
    await page.goto(BASE_URL);
    await page.locator('[name="username"]').type(USERNAME);
    await page.locator('[name="password"]').type(PASSWORD);
    await page.locator('[type="submit"]').click();
  } finally {
    await page.close();
  }
}
Run with custom values:
k6 run -e BASE_URL=https://staging.example.com -e USERNAME=testuser script.js

Summary

1
Resource Management
2
Always close pages with try/finally blocks for proper cleanup.
3
Robust Selectors
4
Use ARIA labels and data attributes instead of class names or generic elements.
5
Hybrid Testing
6
Combine 10% browser VUs with 90% protocol-level VUs for efficient testing.
7
Code Organization
8
Implement page object model for large test suites.
9
Performance Monitoring
10
Set thresholds for Web Vitals and custom metrics.

Next Steps

Overview

Review browser testing fundamentals

Getting Started

Write your first browser test

Build docs developers (and LLMs) love