Run End-to-End Tests
Run browser tests against Browserless without installing or managing Chrome locally. Choose the REST API for lightweight smoke tests, a browser framework for full assertion libraries and parallel execution, or BQL to extract and assert against structured page data.
- A Browserless API token from your account dashboard
Steps
- REST API
- Frameworks
- BQL
The /function endpoint lets you send a browser script as the request body and get structured JSON back. It's the fastest way to drop a smoke test into a CI pipeline without adding a framework dependency. You're just sending HTTP and checking a passed boolean.
- cURL
- JavaScript
- Python
- Java
- C#
1. Write the test script
The /function endpoint accepts a JavaScript module as the request body. The exported function receives a page object and can return any JSON-serializable value:
2. Send the request
curl -X POST \
"https://production-sfo.browserless.io/function?token=YOUR_API_TOKEN_HERE" \
-H "Content-Type: application/javascript" \
--data-raw 'module.exports = async ({ page }) => {
await page.goto("https://example.com");
const title = await page.title();
const heading = await page.$eval("h1", el => el.textContent.trim());
const hasLink = (await page.$("a[href]")) !== null;
const results = [
{ test: "title includes Example Domain", passed: title.includes("Example Domain") },
{ test: "h1 reads Example Domain", passed: heading === "Example Domain" },
{ test: "page has at least one link", passed: hasLink },
];
return { passed: results.every(r => r.passed), results };
};'
3. Check the output
{
"passed": true,
"results": [
{ "test": "title includes Example Domain", "passed": true },
{ "test": "h1 reads Example Domain", "passed": true },
{ "test": "page has at least one link", "passed": true }
]
}
1. Send the request
const code = `module.exports = async ({ page }) => {
await page.goto('https://example.com');
const title = await page.title();
const heading = await page.$eval('h1', el => el.textContent.trim());
const hasLink = (await page.$('a[href]')) !== null;
const results = [
{ test: 'title includes Example Domain', passed: title.includes('Example Domain') },
{ test: 'h1 reads Example Domain', passed: heading === 'Example Domain' },
{ test: 'page has at least one link', passed: hasLink },
];
return { passed: results.every(r => r.passed), results };
};`;
const response = await fetch(
'https://production-sfo.browserless.io/function?token=YOUR_API_TOKEN_HERE',
{
method: 'POST',
headers: { 'Content-Type': 'application/javascript' },
body: code,
}
);
const { passed, results } = await response.json();
results.forEach(r => console.log(`${r.passed ? '✓' : '✗'} ${r.test}`));
if (!passed) process.exit(1);
2. Check the output
Run with node e2e-testing.mjs. Each test prints a pass or fail line. The process exits with code 1 if any test fails, so CI systems pick up the failure automatically.
1. Install dependencies
pip install requests
2. Send the request
import sys
import requests
code = """
module.exports = async ({ page }) => {
await page.goto('https://example.com');
const title = await page.title();
const heading = await page.$eval('h1', el => el.textContent.trim());
const hasLink = (await page.$('a[href]')) !== null;
const results = [
{ test: 'title includes Example Domain', passed: title.includes('Example Domain') },
{ test: 'h1 reads Example Domain', passed: heading === 'Example Domain' },
{ test: 'page has at least one link', passed: hasLink },
];
return { passed: results.every(r => r.passed), results };
};
"""
response = requests.post(
'https://production-sfo.browserless.io/function?token=YOUR_API_TOKEN_HERE',
headers={'Content-Type': 'application/javascript'},
data=code.encode('utf-8'),
)
data = response.json()
for result in data['results']:
icon = '✓' if result['passed'] else '✗'
print(f"{icon} {result['test']}")
if not data['passed']:
sys.exit(1)
3. Check the output
Run with python e2e_testing.py. The process exits with code 1 if any test fails.
1. Send the request
import java.net.URI;
import java.net.http.*;
String token = "YOUR_API_TOKEN_HERE";
String endpoint = "https://production-sfo.browserless.io/function?token=" + token;
String code = """
module.exports = async ({ page }) => {
await page.goto('https://example.com');
const title = await page.title();
const heading = await page.$eval('h1', el => el.textContent.trim());
const hasLink = (await page.$('a[href]')) !== null;
const results = [
{ test: 'title includes Example Domain', passed: title.includes('Example Domain') },
{ test: 'h1 reads Example Domain', passed: heading === 'Example Domain' },
{ test: 'page has at least one link', passed: hasLink },
];
return { passed: results.every(r => r.passed), results };
};
""";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint))
.header("Content-Type", "application/javascript")
.POST(HttpRequest.BodyPublishers.ofString(code))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
2. Check the output
Run the class. The response body is JSON with a passed boolean and a results array containing one entry per assertion.
1. Send the request
using System.Net.Http;
using System.Text;
string token = "YOUR_API_TOKEN_HERE";
string endpoint = $"https://production-sfo.browserless.io/function?token={token}";
string code = @"module.exports = async ({ page }) => {
await page.goto('https://example.com');
const title = await page.title();
const heading = await page.$eval('h1', el => el.textContent.trim());
const hasLink = (await page.$('a[href]')) !== null;
const results = [
{ test: 'title includes Example Domain', passed: title.includes('Example Domain') },
{ test: 'h1 reads Example Domain', passed: heading === 'Example Domain' },
{ test: 'page has at least one link', passed: hasLink },
];
return { passed: results.every(r => r.passed), results };
};";
using (HttpClient httpClient = new HttpClient())
{
var content = new StringContent(code, Encoding.UTF8, "application/javascript");
var response = await httpClient.PostAsync(endpoint, content);
string result = await response.Content.ReadAsStringAsync();
Console.WriteLine(result);
}
2. Check the output
Run the program. The response body is JSON with a passed boolean and a results array containing one entry per assertion.
Use a browser framework when you need a full assertion library, multi-step flows, or parallel test execution. Frameworks give you retrying assertions, test lifecycle hooks, and reporters that the raw REST API doesn't provide.
- Puppeteer
- Playwright (JS)
- Playwright (Python)
- Playwright (Java)
- Playwright (C#)
1. Install dependencies
npm install puppeteer-core
2. Connect and run assertions
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({
browserWSEndpoint: 'wss://production-sfo.browserless.io?token=YOUR_API_TOKEN_HERE',
});
const results = [];
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
const title = await page.title();
results.push({
test: 'title includes "Example Domain"',
passed: title.includes('Example Domain'),
});
const heading = await page.$eval('h1', el => el.textContent.trim());
results.push({
test: 'h1 reads "Example Domain"',
passed: heading === 'Example Domain',
});
const link = await page.$('a[href]');
results.push({
test: 'page has at least one link',
passed: link !== null,
});
} finally {
await browser.close();
}
const failed = results.filter(r => !r.passed);
results.forEach(r => console.log(`${r.passed ? '✓' : '✗'} ${r.test}`));
if (failed.length) {
console.error(`\n${failed.length} test(s) failed`);
process.exit(1);
}
3. Check the output
Run with node e2e-testing.mjs. The process exits with code 1 if any assertion fails, so your CI system registers it as a failure.
✓ title includes "Example Domain"
✓ h1 reads "Example Domain"
✓ page has at least one link
1. Install dependencies
npm install @playwright/test playwright-core
2. Configure Browserless
Add a playwright.config.js file. The wsEndpoint uses /chromium/playwright rather than a plain CDP endpoint because Playwright's connection protocol requires the dedicated Playwright path. Plain CDP won't negotiate the right capabilities. Set workers to control parallelism, retries: 1 so flaky tests get one retry, and trace: 'on-first-retry' so Playwright captures a trace zip only on that retry. Skipping it on the first pass keeps things fast, and you only need it when something actually fails:
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: 4,
retries: 1,
use: {
trace: 'on-first-retry',
connectOptions: {
wsEndpoint: 'wss://production-sfo.browserless.io/chromium/playwright?token=YOUR_API_TOKEN_HERE',
},
},
});
3. Write tests
Playwright's expect() assertions are web-first. They poll the DOM until the condition passes or the timeout expires, so you don't need manual waits. Use page.route() inside individual tests to intercept or mock specific network requests without affecting other tests:
// tests/example.spec.js
import { test, expect } from '@playwright/test';
test('page has correct title', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
});
test('heading is visible and correct', async ({ page }) => {
await page.goto('https://example.com');
const heading = page.locator('h1');
await expect(heading).toBeVisible();
await expect(heading).toHaveText('Example Domain');
});
test('page contains a link', async ({ page }) => {
await page.goto('https://example.com');
await expect(page.locator('a[href]').first()).toBeVisible();
});
test('page loads without analytics calls', async ({ page }) => {
const blocked = [];
await page.route('**/analytics/**', route => {
blocked.push(route.request().url());
route.abort();
});
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
console.log(`Blocked ${blocked.length} analytics request(s)`);
});
4. Run the tests
npx playwright test
5. Cross-browser testing
Browserless exposes separate endpoints for Chromium, Firefox, and WebKit. Use Playwright's projects array to target all three from the same config. Each project connects to its own endpoint and Playwright runs them as distinct test suites:
import { defineConfig } from '@playwright/test';
const token = 'YOUR_API_TOKEN_HERE';
export default defineConfig({
workers: 4,
retries: 1,
use: { trace: 'on-first-retry' },
projects: [
{
name: 'chromium',
use: {
connectOptions: {
wsEndpoint: `wss://production-sfo.browserless.io/chromium/playwright?token=${token}`,
},
},
},
{
name: 'firefox',
use: {
connectOptions: {
wsEndpoint: `wss://production-sfo.browserless.io/firefox/playwright?token=${token}`,
},
},
},
{
name: 'webkit',
use: {
connectOptions: {
wsEndpoint: `wss://production-sfo.browserless.io/webkit/playwright?token=${token}`,
},
},
},
],
});
Run with npx playwright test. Playwright sends each test to the matching Browserless browser.
6. Debug failed tests with Trace Viewer
With trace: 'on-first-retry' set, Playwright writes a trace.zip to test-results/ whenever a test fails its first run. Open it locally:
npx playwright show-trace test-results/<test-name>/trace.zip
The Trace Viewer gives you a step-by-step timeline with DOM snapshots, network requests, and console logs. No SSH or access to the remote machine needed.
7. Check the output
Running 4 tests using 4 workers
✓ [chromium] › example.spec.js:3 › page has correct title (1.2s)
✓ [chromium] › example.spec.js:8 › heading is visible and correct (0.9s)
✓ [chromium] › example.spec.js:14 › page contains a link (0.7s)
✓ [chromium] › example.spec.js:20 › page loads without analytics calls (1.1s)
4 passed (1.5s)
1. Install dependencies
pip install pytest pytest-playwright playwright pytest-xdist
playwright install
2. Configure Browserless
Override the browser fixture in conftest.py to connect to Browserless instead of launching a local browser. The scope="session" means a single browser connection is shared across all tests in the run. Individual tests each get their own page from the default page fixture:
# conftest.py
import pytest
from playwright.sync_api import Playwright
@pytest.fixture(scope="session")
def browser(playwright: Playwright):
browser = playwright.chromium.connect(
"wss://production-sfo.browserless.io/chromium/playwright?token=YOUR_API_TOKEN_HERE"
)
yield browser
browser.close()
3. Write tests
# tests/test_example.py
from playwright.sync_api import expect
def test_page_has_correct_title(page):
page.goto("https://example.com")
expect(page).to_have_title("Example Domain")
def test_heading_is_visible(page):
page.goto("https://example.com")
heading = page.locator("h1")
expect(heading).to_be_visible()
expect(heading).to_have_text("Example Domain")
def test_page_has_link(page):
page.goto("https://example.com")
expect(page.locator("a[href]").first).to_be_visible()
def test_page_loads_without_analytics(page):
blocked = []
def handle_route(route):
blocked.append(route.request.url)
route.abort()
page.route("**/analytics/**", handle_route)
page.goto("https://example.com")
expect(page).to_have_title("Example Domain")
print(f"Blocked {len(blocked)} analytics request(s)")
4. Run the tests
pytest tests/ -n 4
The -n 4 flag (from pytest-xdist) runs 4 tests in parallel, each getting its own Browserless session.
5. Cross-browser testing
Parameterize the browser fixture so every test runs against Chromium, Firefox, and WebKit. Each parameterized value produces a separate browser instance connecting to its own endpoint:
# conftest.py
import pytest
from playwright.sync_api import Playwright
TOKEN = "YOUR_API_TOKEN_HERE"
ENDPOINTS = {
"chromium": f"wss://production-sfo.browserless.io/chromium/playwright?token={TOKEN}",
"firefox": f"wss://production-sfo.browserless.io/firefox/playwright?token={TOKEN}",
"webkit": f"wss://production-sfo.browserless.io/webkit/playwright?token={TOKEN}",
}
@pytest.fixture(scope="session", params=["chromium", "firefox", "webkit"])
def browser(playwright: Playwright, request):
name = request.param
b = getattr(playwright, name).connect(ENDPOINTS[name])
yield b
b.close()
6. Debug with Trace Viewer
Pass --tracing retain-on-failure to capture traces only for failing tests, so you're not storing trace zips for every passing run:
pytest --tracing retain-on-failure
Traces are saved to test-results/. Open one with:
playwright show-trace test-results/<test-name>/trace.zip
7. Check the output
PASSED tests/test_example.py::test_page_has_correct_title
PASSED tests/test_example.py::test_heading_is_visible
PASSED tests/test_example.py::test_page_has_link
PASSED tests/test_example.py::test_page_loads_without_analytics
4 passed in 2.3s
1. Install dependencies
Add to pom.xml:
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.40.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
2. Set up the test class
Connect to Browserless once in @BeforeAll so the WebSocket handshake only happens once per suite. Open a fresh context per test in @BeforeEach. Separate contexts give each test isolated cookies, storage, and network state, so tests can't bleed state into each other:
import com.microsoft.playwright.*;
import org.junit.jupiter.api.*;
class ExampleTest {
static Playwright playwright;
static Browser browser;
BrowserContext context;
Page page;
@BeforeAll
static void connect() {
playwright = Playwright.create();
browser = playwright.chromium().connect(
"wss://production-sfo.browserless.io/chromium/playwright?token=YOUR_API_TOKEN_HERE"
);
}
@AfterAll
static void disconnect() {
playwright.close();
}
@BeforeEach
void createContextAndPage() {
context = browser.newContext();
page = context.newPage();
}
@AfterEach
void closeContext() {
context.close();
}
}
3. Write tests
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import java.util.*;
import java.util.regex.Pattern;
@Test
void pageHasCorrectTitle() {
page.navigate("https://example.com");
assertThat(page).hasTitle(Pattern.compile("Example Domain"));
}
@Test
void headingIsVisible() {
page.navigate("https://example.com");
Locator heading = page.locator("h1");
assertThat(heading).isVisible();
assertThat(heading).hasText("Example Domain");
}
@Test
void pageHasLink() {
page.navigate("https://example.com");
assertThat(page.locator("a[href]").first()).isVisible();
}
@Test
void pageLoadsWithoutAnalytics() {
List<String> blocked = new ArrayList<>();
page.route("**/analytics/**", route -> {
blocked.add(route.request().url());
route.abort();
});
page.navigate("https://example.com");
assertThat(page).hasTitle(Pattern.compile("Example Domain"));
System.out.println("Blocked " + blocked.size() + " analytics request(s)");
}
4. Run the tests
mvn test
To run in parallel, configure the Surefire plugin in pom.xml:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>methods</parallel>
<threadCount>4</threadCount>
</configuration>
</plugin>
5. Cross-browser testing
Use @ParameterizedTest with @MethodSource to run the same test method against each browser endpoint. Each invocation opens its own Playwright instance so browser types don't share state:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
static Stream<Arguments> browserEndpoints() {
String token = "YOUR_API_TOKEN_HERE";
String base = "wss://production-sfo.browserless.io";
return Stream.of(
Arguments.of("chromium", base + "/chromium/playwright?token=" + token),
Arguments.of("firefox", base + "/firefox/playwright?token=" + token),
Arguments.of("webkit", base + "/webkit/playwright?token=" + token)
);
}
@ParameterizedTest(name = "{0}")
@MethodSource("browserEndpoints")
void pageHasCorrectTitle(String browserName, String wsEndpoint) {
try (Playwright pw = Playwright.create()) {
Browser b = pw.chromium().connect(wsEndpoint);
Page p = b.newPage();
p.navigate("https://example.com");
assertThat(p).hasTitle(Pattern.compile("Example Domain"));
}
}
6. Debug with Trace Viewer
Start and stop tracing in @BeforeEach / @AfterEach so each test gets its own trace file:
import java.nio.file.Paths;
import org.junit.jupiter.api.TestInfo;
@BeforeEach
void createContextAndPage() {
context = browser.newContext();
context.tracing().start(new Tracing.StartOptions()
.setScreenshots(true).setSnapshots(true));
page = context.newPage();
}
@AfterEach
void closeContext(TestInfo testInfo) {
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("trace-" + testInfo.getDisplayName() + ".zip")));
context.close();
}
Open the trace:
mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="show-trace trace.zip"
7. Check the output
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
1. Install dependencies
dotnet add package Microsoft.Playwright.NUnit
2. Set up the test class
Connect to Browserless once in [OneTimeSetUp] so the WebSocket handshake only happens once per suite. Open a fresh context per test in [SetUp]. Separate contexts give each test isolated cookies, storage, and network state, so tests can't bleed state into each other:
using Microsoft.Playwright;
using NUnit.Framework;
using System.Collections.Generic;
using System.Threading.Tasks;
[Parallelizable(ParallelScope.Self)]
[TestFixture]
public class ExampleTests
{
private IPlaywright _playwright;
private IBrowser _browser;
private IBrowserContext _context;
private IPage _page;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.ConnectAsync(
"wss://production-sfo.browserless.io/chromium/playwright?token=YOUR_API_TOKEN_HERE"
);
}
[SetUp]
public async Task SetUp()
{
_context = await _browser.NewContextAsync();
_page = await _context.NewPageAsync();
}
[TearDown]
public async Task TearDown()
{
await _context.CloseAsync();
}
[OneTimeTearDown]
public async Task OneTimeTearDown()
{
await _playwright.DisposeAsync();
}
}
3. Write tests
using System.Text.RegularExpressions;
[Test]
public async Task PageHasCorrectTitle()
{
await _page.GotoAsync("https://example.com");
await Assertions.Expect(_page).ToHaveTitleAsync(new Regex("Example Domain"));
}
[Test]
public async Task HeadingIsVisible()
{
await _page.GotoAsync("https://example.com");
var heading = _page.Locator("h1");
await Assertions.Expect(heading).ToBeVisibleAsync();
await Assertions.Expect(heading).ToHaveTextAsync("Example Domain");
}
[Test]
public async Task PageHasLink()
{
await _page.GotoAsync("https://example.com");
await Assertions.Expect(_page.Locator("a[href]").First).ToBeVisibleAsync();
}
[Test]
public async Task PageLoadsWithoutAnalytics()
{
var blocked = new List<string>();
await _page.RouteAsync("**/analytics/**", async route =>
{
blocked.Add(route.Request.Url);
await route.AbortAsync();
});
await _page.GotoAsync("https://example.com");
await Assertions.Expect(_page).ToHaveTitleAsync(new Regex("Example Domain"));
Console.WriteLine($"Blocked {blocked.Count} analytics request(s)");
}
4. Run the tests
dotnet test
To configure parallelism, add a .runsettings file:
<RunSettings>
<RunConfiguration>
<MaxCpuCount>4</MaxCpuCount>
</RunConfiguration>
</RunSettings>
Then run with dotnet test --settings .runsettings.
5. Cross-browser testing
Use NUnit's [TestCaseSource] to feed each browser endpoint as a separate test case. Each case creates its own Playwright instance so browser types don't share state:
private static IEnumerable<TestCaseData> BrowserEndpoints()
{
var token = "YOUR_API_TOKEN_HERE";
var @base = "wss://production-sfo.browserless.io";
yield return new TestCaseData("chromium", $"{@base}/chromium/playwright?token={token}");
yield return new TestCaseData("firefox", $"{@base}/firefox/playwright?token={token}");
yield return new TestCaseData("webkit", $"{@base}/webkit/playwright?token={token}");
}
[Test, TestCaseSource(nameof(BrowserEndpoints))]
public async Task PageHasCorrectTitle(string browserName, string wsEndpoint)
{
var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsEndpoint);
var page = await browser.NewPageAsync();
await page.GotoAsync("https://example.com");
await Assertions.Expect(page).ToHaveTitleAsync(new Regex("Example Domain"));
await playwright.DisposeAsync();
}
6. Debug with Trace Viewer
Start tracing in [SetUp] and stop it in [TearDown] so each test gets its own trace file:
[SetUp]
public async Task SetUp()
{
_context = await _browser.NewContextAsync();
await _context.Tracing.StartAsync(new TracingStartOptions
{
Screenshots = true,
Snapshots = true,
});
_page = await _context.NewPageAsync();
}
[TearDown]
public async Task TearDown()
{
await _context.Tracing.StopAsync(new TracingStopOptions
{
Path = $"trace-{TestContext.CurrentContext.Test.Name}.zip",
});
await _context.CloseAsync();
}
Open the trace:
pwsh bin/Debug/net8.0/playwright.ps1 show-trace trace.zip
7. Check the output
Test Run Successful.
Total tests: 4
Passed: 4
Total time: 2.5000 Seconds
1. Write the mutation
Navigate to the page and select the content you want to verify. BQL returns typed structured data, so you write your assertions in your own application code after the response arrives. No assertion library runs inside Browserless:
mutation E2ECheck {
goto(url: "https://example.com", waitUntil: domContentLoaded) {
status
}
title: mapSelector(selector: "title") {
innerText
}
heading: mapSelector(selector: "h1") {
innerText
}
links: mapSelector(selector: "a[href]") {
href: attribute(name: "href") {
value
}
}
}
2. Run it
Paste into the BQL IDE and click Run.
3. Check the output
{
"data": {
"goto": { "status": 200 },
"title": [{ "innerText": "Example Domain" }],
"heading": [{ "innerText": "Example Domain" }],
"links": [{ "href": { "value": "https://www.iana.org/domains/example" } }]
}
}
Assert against the values in your application code. Check goto.status for the HTTP status code, innerText fields for content, array lengths for element counts, and attribute fields for link targets.
Next steps
- Run Concurrent Browser Sessions — run tests in parallel across multiple browsers
- Log In and Reuse Sessions — authenticate once and reuse state across test runs
- Disconnect and Reconnect to a Browser — pause and resume long-running test sessions