How to Write Browser Tests
Writing effective browser tests requires understanding asynchronous operations, element selection, and page navigation. This guide covers the essential patterns and techniques you need to know.
Asynchronous Operations
Most methods in the browser module return JavaScript promises, so k6 scripts must use the await keyword to wait for async operations to complete.
Basic Async Pattern
import { browser } from 'k6/browser' ;
export default async function () {
const page = await browser . newPage ();
await page . goto ( 'https://quickpizza.grafana.com/' );
const locator = page . locator ( 'button[name="pizza-please"]' );
await locator . click ();
await page . close ();
}
Why Use Async/Await?
The browser module uses asynchronous APIs for several reasons:
JavaScript is single-threaded - async APIs prevent blocking the event loop
Consistency with Playwright and other frontend testing frameworks
Alignment with modern JavaScript development practices
If you forget await on asynchronous APIs, the script may finish before the test completes, resulting in errors like "Uncaught (in promise) TypeError: Object has no member 'goto'".
Handling Page Navigation
There are two recommended methods for handling page navigations: using Promise.all or using waitFor.
Using Promise.all
When actions trigger page navigation, use Promise.all to wait for both the action and navigation:
import { browser } from 'k6/browser' ;
import { check } from 'k6' ;
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/my_messages.php' );
await page . locator ( 'input[name="login"]' ). type ( 'admin' );
await page . locator ( 'input[name="password"]' ). type ( '123' );
const submitButton = page . locator ( 'input[type="submit"]' );
// Wait for both navigation and button click
await Promise . all ([ page . waitForNavigation (), submitButton . click ()]);
check ( page . locator ( 'h2' ), {
header : async ( lo ) => ( await lo . textContent ()) == 'Welcome, admin!' ,
});
} finally {
await page . close ();
}
}
This prevents race conditions by ensuring the page is ready before continuing.
Wait for Specific Elements
Use locator.waitFor() to wait for important elements to appear:
await page . goto ( 'https://my-search-engine.com' );
const searchBar = page . locator ( '.search-bar' );
const submitButton = page . locator ( '.submit-button' );
// Wait for elements to appear
await searchBar . waitFor ();
await submitButton . waitFor ();
// Interact with elements
await searchBar . fill ( 'k6' );
await submitButton . click ();
const searchResults = page . locator ( '.search-results-table' );
await searchResults . waitFor ();
This approach is often clearer than using Promise.all and makes scripts easier to follow.
Interacting with Elements
Use page.locator() to find elements and interact with them.
Finding Elements
import { browser } from 'k6/browser' ;
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/my_messages.php' );
// Find elements using selectors
await page . locator ( 'input[name="login"]' ). type ( 'admin' );
await page . locator ( 'input[name="password"]' ). type ( '123' );
await page . screenshot ({ path: 'screenshot.png' });
} finally {
await page . close ();
}
}
Common Locator Methods
Type Text
Click
Get Text
Check Box
Fill Input
const input = page . locator ( 'input[name="username"]' );
await input . type ( 'myusername' );
Selecting Elements
k6 browser supports CSS and XPath selectors. Choose selectors that are robust to DOM changes.
Recommended Selectors
Best: User-facing attributes like ARIA labels and data attributespage . locator ( '[aria-label="Login"]' )
page . locator ( '[data-test="login"]' )
Good: Text selectors using XPathpage . locator ( '//button[text()="Submit"]' )
Use sparingly: IDs and class namespage . locator ( '#login-btn' ) // OK if ID doesn't change
page . locator ( '.login-btn' ) // Can be duplicated
Avoid: Generic elements and absolute pathspage . locator ( 'button' ) // No context
page . locator ( '/html[1]/body[1]/main[1]' ) // Brittle
Hybrid Testing Pattern
Combine browser tests with protocol-level tests using scenarios:
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
load: {
exec: 'getPizza' ,
executor: 'ramping-vus' ,
stages: [
{ duration: '5s' , target: 5 },
{ duration: '10s' , target: 5 },
{ duration: '5s' , target: 0 },
],
startTime: '10s' ,
},
// Browser-level test
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 ();
}
}
Benefits of Hybrid Testing
Test real user flows on the frontend while generating higher load in the backend
Measure backend and frontend performance in the same test execution
Increase collaboration between backend and frontend teams
Page Object Model
For large test suites, use the page object model pattern to improve maintainability:
// pages/homepage.js
export class Homepage {
constructor ( page ) {
this . page = page ;
this . nameField = page . locator ( '[data-testid="ContactName"]' );
this . emailField = page . locator ( '[data-testid="ContactEmail"]' );
this . submitButton = page . locator ( '#submitContact' );
this . verificationMessage = page . locator ( '.row.contact h2' );
}
async goto () {
await this . page . goto ( 'https://myexamplewebsite/' );
}
async submitForm ( name , email ) {
await this . nameField . type ( name );
await this . emailField . type ( email );
await this . submitButton . click ();
}
async getVerificationMessage () {
return await this . verificationMessage . innerText ();
}
}
// test.js
import { browser } from 'k6/browser' ;
import { Homepage } from './pages/homepage.js' ;
export default async function () {
const page = await browser . newPage ();
const homepage = new Homepage ( page );
await homepage . goto ();
await homepage . submitForm ( 'John Doe' , '[email protected] ' );
const message = await homepage . getVerificationMessage ();
console . log ( message );
await page . close ();
}
Setting Thresholds
Set thresholds for browser metrics to define performance SLOs:
export const options = {
scenarios: {
ui: {
executor: 'shared-iterations' ,
options: {
browser: {
type: 'chromium' ,
},
},
},
},
thresholds: {
'browser_web_vital_lcp' : [ 'p(90) < 1000' ],
'browser_web_vital_fcp' : [ 'p(95) < 800' ],
'browser_web_vital_inp' : [ 'p(90) < 100' ],
checks: [ 'rate==1.0' ],
},
};
Next Steps
Best Practices Learn optimization techniques and patterns
Overview Review browser testing fundamentals