Headless browser testing runs automated tests without drawing the browser on screen. It's quicker to run, lighter on resources, and fits naturally into CI/CD pipelines, where servers usually have no display at all. Selenium supports headless modes for Chrome, Firefox and Edge, and since Selenium Manager arrived, getting started is much simpler than it used to be.
This guide covers how headless testing works, how to configure it in Selenium for each major browser, how to run it in Jenkins, GitHub Actions and Azure DevOps, the problems teams run into, and where local headless infrastructure starts to hold you back.
What is Headless Browser Testing
Headless browser testing runs automated tests in a browser that has no visible window. The browser still does everything it normally would: it loads pages, runs JavaScript, fills in forms, clicks buttons and navigates between pages. It just never draws any of it on screen.
Technical Definition
A headless browser is a full browser engine (Chromium, Gecko or WebKit) that executes JavaScript, processes CSS, builds the DOM and handles network requests, all without opening a window. You control it through code rather than a user interface.
Traditional Browser Execution:
Test script → WebDriver → Browser window opens
→ Page loads and renders on screen
→ Actions performed visibly → Results returned
Test script → WebDriver → Browser window opens
→ Page loads and renders on screen
→ Actions performed visibly → Results returned
Test script → WebDriver → Browser window opens
→ Page loads and renders on screen
→ Actions performed visibly → Results returned
Test script → WebDriver → Browser window opens
→ Page loads and renders on screen
→ Actions performed visibly → Results returned
Headless Browser Execution:
Test script → WebDriver → Browser starts with no window
→ Page loads and runs in memory
→ Actions performed in the background → Results returned
Test script → WebDriver → Browser starts with no window
→ Page loads and runs in memory
→ Actions performed in the background → Results returned
Test script → WebDriver → Browser starts with no window
→ Page loads and runs in memory
→ Actions performed in the background → Results returned
Test script → WebDriver → Browser starts with no window
→ Page loads and runs in memory
→ Actions performed in the background → Results returned
Historical Context
Early headless testing relied on tools like PhantomJS or HtmlUnitDriver, which imitated a browser without using a real browser engine. That meant tests could pass headlessly and still fail for real users. Modern headless testing uses the actual browsers (Chrome, Firefox and Edge) running in headless mode, so what you test behaves like what your users get.
Why Headless Browser Testing Matters
Performance Optimization
Faster Execution Speed
Headless browsers skip the work of painting pixels to a screen, so tests usually finish faster than in a visible browser. The gain varies with the application, but on large regression suites with hundreds of tests, the time saved adds up across every run.
The browser still loads CSS, JavaScript and images and builds the page in full. It simply doesn't draw the result, which keeps behaviour realistic while cutting overhead.
Resource Efficiency
Lower memory use
Without the display layer, GPU work and visual buffers, a headless browser typically uses noticeably less memory than a visible one.
Lower CPU use
With no screen painting, animations or visual effects to process, more CPU is left for running the tests themselves.
More parallel capacity
Because each instance is lighter, the same machine can run more headless browsers side by side than visible ones.
CI/CD Pipeline Integration
Works on servers without displays
CI/CD servers in Jenkins, GitHub Actions and Azure DevOps usually run without a screen. Headless browsers run there naturally, with no need to configure a virtual display.
Enables continuous testing
Tests triggered on every commit, pull request or deployment run efficiently in headless mode, giving developers fast feedback.
Shorter build times
Faster test runs keep pipelines responsive, so pull requests get results in minutes rather than sitting in a queue.
Cost and Scalability Benefits
Lower CI costs
Cloud CI runners charge by the minute. Faster, lighter tests mean fewer billable minutes across thousands of pipeline runs.
Easier scaling
Lightweight headless instances spread across several machines more easily, which makes distributed test runs more affordable.
Headless Browser Options for Selenium
Headless Chrome (Chromium-Based)
Market Position
Chrome has the largest share of the desktop browser market, which makes headless Chrome the most common choice for automated testing.
Technical Capabilities:
The full Chromium engine, with complete JavaScript support
DevTools Protocol access for advanced debugging
Built-in PDF generation and screenshot capture
Strong performance and stability
Support for modern CSS and web standards
Selenium Configuration:
from selenium import webdriver
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()Note: Chrome introduced a new headless mode in version 109, and from Chrome 132 the old mode was removed entirely. Both --headless and --headless=new now run the new mode, which behaves almost exactly like visible Chrome. If you specifically need the old, lighter headless engine, Google now ships it separately as chrome-headless-shell.
You don't need to download ChromeDriver yourself. From Selenium 4.6 onwards, Selenium Manager finds or downloads the right driver automatically.
Headless Firefox
Market Position
Firefox has a smaller share of users than Chrome, but it's still important for cross-browser testing because it uses a completely different engine. Bugs that never appear in Chromium can show up in Firefox.
Technical Capabilities:
The Gecko engine, with strong standards compliance
Robust privacy and security features
Good performance on modern web applications
Reliable handling of complex DOM structures
Selenium Configuration:
from selenium import webdriver
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.add_argument("--width=1920")
options.add_argument("--height=1080")
driver = webdriver.Firefox(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.add_argument("--width=1920")
options.add_argument("--height=1080")
driver = webdriver.Firefox(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.add_argument("--width=1920")
options.add_argument("--height=1080")
driver = webdriver.Firefox(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.add_argument("--width=1920")
options.add_argument("--height=1080")
driver = webdriver.Firefox(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()Alternative Syntax:
You can also switch Firefox into headless mode with an environment variable, which is handy in CI where you don't want to change the code:
export MOZ_HEADLESS=1
pytest tests
export MOZ_HEADLESS=1
pytest tests
export MOZ_HEADLESS=1
pytest tests
export MOZ_HEADLESS=1
pytest tests
The older options.headless = True property was removed in Selenium 4.10, so any code using it needs updating to one of the approaches above.
Headless Edge
Market Position
Microsoft Edge has been built on Chromium since 2020. It matters most for organisations standardised on Microsoft tools, where it's often the default browser.
Technical Capabilities:
The Chromium engine, with performance similar to Chrome
Integration with Windows enterprise features
A good fit for testing Microsoft 365 and Azure-based applications
The same headless capabilities as Chrome
Selenium Configuration:
from selenium import webdriver
options = webdriver.EdgeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Edge(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.EdgeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Edge(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.EdgeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Edge(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()from selenium import webdriver
options = webdriver.EdgeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Edge(options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()What About Safari?
Safari doesn't support headless mode. SafariDriver only runs a visible browser, and only on macOS. If Safari coverage matters to your users, you'll need macOS machines running visible Safari, or a cloud platform that provides real Safari instances.
HtmlUnitDriver (Legacy)
Historical Context
HtmlUnitDriver was Selenium's original headless option. It simulates a browser in Java rather than using a real browser engine.
Current Status
It's now maintained as a separate project and is rarely used for modern testing. Its JavaScript support and rendering accuracy lag well behind real browsers, so tests can pass in HtmlUnit and still fail for users.
WebDriver driver = new HtmlUnitDriver(true);
driver.get("https://example.com");
WebDriver driver = new HtmlUnitDriver(true);
driver.get("https://example.com");
WebDriver driver = new HtmlUnitDriver(true);
driver.get("https://example.com");
WebDriver driver = new HtmlUnitDriver(true);
driver.get("https://example.com");When to Consider it
Only for very lightweight checks on simple pages where JavaScript barely matters and exact browser behaviour isn't important.
Complete Headless Testing Implementation
1. Python Implementation
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def test_user_can_sign_in(driver):
driver.get("https://example.com/login")
wait = WebDriverWait(driver, 10)
wait.until(EC.visibility_of_element_located((By.ID, "email"))).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
heading = wait.until(EC.visibility_of_element_located((By.TAG_NAME, "h1")))
assert "Welcome" in heading.textimport pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def test_user_can_sign_in(driver):
driver.get("https://example.com/login")
wait = WebDriverWait(driver, 10)
wait.until(EC.visibility_of_element_located((By.ID, "email"))).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
heading = wait.until(EC.visibility_of_element_located((By.TAG_NAME, "h1")))
assert "Welcome" in heading.textimport pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def test_user_can_sign_in(driver):
driver.get("https://example.com/login")
wait = WebDriverWait(driver, 10)
wait.until(EC.visibility_of_element_located((By.ID, "email"))).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
heading = wait.until(EC.visibility_of_element_located((By.TAG_NAME, "h1")))
assert "Welcome" in heading.textimport pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
@pytest.fixture
def driver():
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
yield driver
driver.quit()
def test_user_can_sign_in(driver):
driver.get("https://example.com/login")
wait = WebDriverWait(driver, 10)
wait.until(EC.visibility_of_element_located((By.ID, "email"))).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
heading = wait.until(EC.visibility_of_element_located((By.TAG_NAME, "h1")))
assert "Welcome" in heading.text2. JavaScript (Node.js) Implementation
const { Builder, By, until } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
(async function userCanSignIn() {
const options = new chrome.Options()
.addArguments('--headless=new', '--window-size=1920,1080');
const driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(options)
.build();
try {
await driver.get('https://example.com/login');
const email = await driver.wait(until.elementLocated(By.id('email')), 10000);
await email.sendKeys('jane@example.com');
await driver.findElement(By.id('password')).sendKeys('S3cure!pass');
await driver.findElement(By.css("button[type='submit']")).click();
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000);
const text = await heading.getText();
if (!text.includes('Welcome')) {
throw new Error(`Unexpected heading: ${text}`);
}
console.log('Sign-in test passed');
} finally {
await driver.quit();
}
})();const { Builder, By, until } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
(async function userCanSignIn() {
const options = new chrome.Options()
.addArguments('--headless=new', '--window-size=1920,1080');
const driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(options)
.build();
try {
await driver.get('https://example.com/login');
const email = await driver.wait(until.elementLocated(By.id('email')), 10000);
await email.sendKeys('jane@example.com');
await driver.findElement(By.id('password')).sendKeys('S3cure!pass');
await driver.findElement(By.css("button[type='submit']")).click();
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000);
const text = await heading.getText();
if (!text.includes('Welcome')) {
throw new Error(`Unexpected heading: ${text}`);
}
console.log('Sign-in test passed');
} finally {
await driver.quit();
}
})();const { Builder, By, until } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
(async function userCanSignIn() {
const options = new chrome.Options()
.addArguments('--headless=new', '--window-size=1920,1080');
const driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(options)
.build();
try {
await driver.get('https://example.com/login');
const email = await driver.wait(until.elementLocated(By.id('email')), 10000);
await email.sendKeys('jane@example.com');
await driver.findElement(By.id('password')).sendKeys('S3cure!pass');
await driver.findElement(By.css("button[type='submit']")).click();
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000);
const text = await heading.getText();
if (!text.includes('Welcome')) {
throw new Error(`Unexpected heading: ${text}`);
}
console.log('Sign-in test passed');
} finally {
await driver.quit();
}
})();const { Builder, By, until } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
(async function userCanSignIn() {
const options = new chrome.Options()
.addArguments('--headless=new', '--window-size=1920,1080');
const driver = await new Builder()
.forBrowser('chrome')
.setChromeOptions(options)
.build();
try {
await driver.get('https://example.com/login');
const email = await driver.wait(until.elementLocated(By.id('email')), 10000);
await email.sendKeys('jane@example.com');
await driver.findElement(By.id('password')).sendKeys('S3cure!pass');
await driver.findElement(By.css("button[type='submit']")).click();
const heading = await driver.wait(until.elementLocated(By.css('h1')), 10000);
const text = await heading.getText();
if (!text.includes('Welcome')) {
throw new Error(`Unexpected heading: ${text}`);
}
console.log('Sign-in test passed');
} finally {
await driver.quit();
}
})();3. C# Implementation
using System;
using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
[TestFixture]
public class LoginTests
{
private IWebDriver driver;
[SetUp]
public void SetUp()
{
var options = new ChromeOptions();
options.AddArgument("--headless=new");
options.AddArgument("--window-size=1920,1080");
driver = new ChromeDriver(options);
}
[Test]
public void UserCanSignIn()
{
driver.Navigate().GoToUrl("https://example.com/login");
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
wait.Until(d => d.FindElement(By.Id("email"))).SendKeys("jane@example.com");
driver.FindElement(By.Id("password")).SendKeys("S3cure!pass");
driver.FindElement(By.CssSelector("button[type='submit']")).Click();
var heading = wait.Until(d => d.FindElement(By.TagName("h1")));
Assert.That(heading.Text, Does.Contain("Welcome"));
}
[TearDown]
public void TearDown()
{
driver.Quit
using System;
using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
[TestFixture]
public class LoginTests
{
private IWebDriver driver;
[SetUp]
public void SetUp()
{
var options = new ChromeOptions();
options.AddArgument("--headless=new");
options.AddArgument("--window-size=1920,1080");
driver = new ChromeDriver(options);
}
[Test]
public void UserCanSignIn()
{
driver.Navigate().GoToUrl("https://example.com/login");
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
wait.Until(d => d.FindElement(By.Id("email"))).SendKeys("jane@example.com");
driver.FindElement(By.Id("password")).SendKeys("S3cure!pass");
driver.FindElement(By.CssSelector("button[type='submit']")).Click();
var heading = wait.Until(d => d.FindElement(By.TagName("h1")));
Assert.That(heading.Text, Does.Contain("Welcome"));
}
[TearDown]
public void TearDown()
{
driver.Quit
using System;
using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
[TestFixture]
public class LoginTests
{
private IWebDriver driver;
[SetUp]
public void SetUp()
{
var options = new ChromeOptions();
options.AddArgument("--headless=new");
options.AddArgument("--window-size=1920,1080");
driver = new ChromeDriver(options);
}
[Test]
public void UserCanSignIn()
{
driver.Navigate().GoToUrl("https://example.com/login");
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
wait.Until(d => d.FindElement(By.Id("email"))).SendKeys("jane@example.com");
driver.FindElement(By.Id("password")).SendKeys("S3cure!pass");
driver.FindElement(By.CssSelector("button[type='submit']")).Click();
var heading = wait.Until(d => d.FindElement(By.TagName("h1")));
Assert.That(heading.Text, Does.Contain("Welcome"));
}
[TearDown]
public void TearDown()
{
driver.Quit
using System;
using NUnit.Framework;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
[TestFixture]
public class LoginTests
{
private IWebDriver driver;
[SetUp]
public void SetUp()
{
var options = new ChromeOptions();
options.AddArgument("--headless=new");
options.AddArgument("--window-size=1920,1080");
driver = new ChromeDriver(options);
}
[Test]
public void UserCanSignIn()
{
driver.Navigate().GoToUrl("https://example.com/login");
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
wait.Until(d => d.FindElement(By.Id("email"))).SendKeys("jane@example.com");
driver.FindElement(By.Id("password")).SendKeys("S3cure!pass");
driver.FindElement(By.CssSelector("button[type='submit']")).Click();
var heading = wait.Until(d => d.FindElement(By.TagName("h1")));
Assert.That(heading.Text, Does.Contain("Welcome"));
}
[TearDown]
public void TearDown()
{
driver.Quit
WebDriverWait lives in the Selenium.Support NuGet package, so add it alongside Selenium.WebDriver.
CI/CD Integration Strategies
Jenkins Integration
Jenkinsfile Configuration:
pipeline {
agent any
stages {
stage('Install dependencies') {
steps {
sh 'python3 -m venv venv'
sh '. venv/bin/activate && pip install -r requirements.txt'
}
}
stage('Run headless tests') {
steps {
sh '. venv/bin/activate && pytest tests/ --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml'
}
}
}pipeline {
agent any
stages {
stage('Install dependencies') {
steps {
sh 'python3 -m venv venv'
sh '. venv/bin/activate && pip install -r requirements.txt'
}
}
stage('Run headless tests') {
steps {
sh '. venv/bin/activate && pytest tests/ --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml'
}
}
}pipeline {
agent any
stages {
stage('Install dependencies') {
steps {
sh 'python3 -m venv venv'
sh '. venv/bin/activate && pip install -r requirements.txt'
}
}
stage('Run headless tests') {
steps {
sh '. venv/bin/activate && pytest tests/ --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml'
}
}
}pipeline {
agent any
stages {
stage('Install dependencies') {
steps {
sh 'python3 -m venv venv'
sh '. venv/bin/activate && pip install -r requirements.txt'
}
}
stage('Run headless tests') {
steps {
sh '. venv/bin/activate && pytest tests/ --junitxml=results.xml'
}
}
}
post {
always {
junit 'results.xml'
}
}
}The Jenkins agent needs Chrome installed. Selenium Manager takes care of the matching driver.
GitHub Actions Integration
.github/workflows/test.yml:
name: Headless Selenium Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run headless tests
run: pytest tests/ --junitxml=results.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path
name: Headless Selenium Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run headless tests
run: pytest tests/ --junitxml=results.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path
name: Headless Selenium Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run headless tests
run: pytest tests/ --junitxml=results.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path
name: Headless Selenium Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run headless tests
run: pytest tests/ --junitxml=results.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path
GitHub's Ubuntu runners come with Chrome preinstalled, so there's no browser setup step.
Azure DevOps Integration
azure-pipelines.yml:
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.12'
- script: pip install -r requirements.txt
displayName: 'Install dependencies'
- script: pytest tests/ --junitxml=results.xml
displayName: 'Run headless tests'
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFiles: 'results.xml'
testRunTitle: 'Headless Selenium Tests'trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.12'
- script: pip install -r requirements.txt
displayName: 'Install dependencies'
- script: pytest tests/ --junitxml=results.xml
displayName: 'Run headless tests'
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFiles: 'results.xml'
testRunTitle: 'Headless Selenium Tests'trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.12'
- script: pip install -r requirements.txt
displayName: 'Install dependencies'
- script: pytest tests/ --junitxml=results.xml
displayName: 'Run headless tests'
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFiles: 'results.xml'
testRunTitle: 'Headless Selenium Tests'trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.12'
- script: pip install -r requirements.txt
displayName: 'Install dependencies'
- script: pytest tests/ --junitxml=results.xml
displayName: 'Run headless tests'
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFiles: 'results.xml'
testRunTitle: 'Headless Selenium Tests'Common Headless Testing Challenges
Challenge 1: Viewport and Resolution Issues
Problem: Tests pass in a visible browser but fail headlessly, because the headless browser starts with a smaller default window. Responsive layouts switch to a different breakpoint, and elements move or disappear.
Solution: Set the window size explicitly:
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.set_window_size(1920, 1080)options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.set_window_size(1920, 1080)options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.set_window_size(1920, 1080)options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.set_window_size(1920, 1080)Challenge 2: Timing and Synchronization
Problem: Headless browsers can run faster than visible ones, which exposes race conditions. A test tries to click a button before it's ready.
Solution: Use explicit waits instead of fixed delays:
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
button = wait.until(EC.element_to_be_clickable((By.ID, "place-order")))
button.click()
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
button = wait.until(EC.element_to_be_clickable((By.ID, "place-order")))
button.click()
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
button = wait.until(EC.element_to_be_clickable((By.ID, "place-order")))
button.click()
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
wait = WebDriverWait(driver, 10)
button = wait.until(EC.element_to_be_clickable((By.ID, "place-order")))
button.click()
Challenge 3: Screenshot Verification
Problem: Screenshots from headless browsers work, but fonts, anti-aliasing and GPU differences can make them vary slightly from visible runs, and between machines.
Solution: Compare screenshots with a small tolerance rather than requiring an exact match:
from PIL import Image, ImageChops
def images_match(baseline_path, current_path, tolerance=0.01):
baseline = Image.open(baseline_path).convert("RGB")
current = Image.open(current_path).convert("RGB")
if baseline.size != current.size:
return False
diff = ImageChops.difference(baseline, current)
changed_pixels = sum(1 for pixel in diff.getdata() if pixel != (0, 0, 0))
total_pixels = baseline.width * baseline.height
return changed_pixels / total_pixels <= tolerance
driver.save_screenshot("current.png")
assert images_match("baseline.png", "current.png")from PIL import Image, ImageChops
def images_match(baseline_path, current_path, tolerance=0.01):
baseline = Image.open(baseline_path).convert("RGB")
current = Image.open(current_path).convert("RGB")
if baseline.size != current.size:
return False
diff = ImageChops.difference(baseline, current)
changed_pixels = sum(1 for pixel in diff.getdata() if pixel != (0, 0, 0))
total_pixels = baseline.width * baseline.height
return changed_pixels / total_pixels <= tolerance
driver.save_screenshot("current.png")
assert images_match("baseline.png", "current.png")from PIL import Image, ImageChops
def images_match(baseline_path, current_path, tolerance=0.01):
baseline = Image.open(baseline_path).convert("RGB")
current = Image.open(current_path).convert("RGB")
if baseline.size != current.size:
return False
diff = ImageChops.difference(baseline, current)
changed_pixels = sum(1 for pixel in diff.getdata() if pixel != (0, 0, 0))
total_pixels = baseline.width * baseline.height
return changed_pixels / total_pixels <= tolerance
driver.save_screenshot("current.png")
assert images_match("baseline.png", "current.png")from PIL import Image, ImageChops
def images_match(baseline_path, current_path, tolerance=0.01):
baseline = Image.open(baseline_path).convert("RGB")
current = Image.open(current_path).convert("RGB")
if baseline.size != current.size:
return False
diff = ImageChops.difference(baseline, current)
changed_pixels = sum(1 for pixel in diff.getdata() if pixel != (0, 0, 0))
total_pixels = baseline.width * baseline.height
return changed_pixels / total_pixels <= tolerance
driver.save_screenshot("current.png")
assert images_match("baseline.png", "current.png")For anything beyond simple checks, a dedicated visual testing tool will handle rendering noise far better.
Challenge 4: Browser Console Access
Problem: Without a visible DevTools window, JavaScript errors in the page are easy to miss.
Solution: Capture console logs in code. In Chrome and Edge, enable browser logging:
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
for entry in driver.get_log("browser"):
print(entry["level"], entry["message"])options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
for entry in driver.get_log("browser"):
print(entry["level"], entry["message"])options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
for entry in driver.get_log("browser"):
print(entry["level"], entry["message"])options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
for entry in driver.get_log("browser"):
print(entry["level"], entry["message"])This logging method only works in Chromium-based browsers. For Firefox, use Selenium's WebDriver BiDi support, which listens for console messages across browsers.
Challenge 5: Resource Cleanup
Problem: When a test fails partway through, the browser process can be left running. Over many runs, these leftover processes pile up and slow the machine down.
Solution: Always quit the driver in a finally block, or use a test fixture that cleans up for you:
driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
finally:
driver.quit()driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
finally:
driver.quit()driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
finally:
driver.quit()driver = webdriver.Chrome(options=options)
try:
driver.get("https://example.com")
finally:
driver.quit()Use driver.quit() rather than driver.close(). close() only shuts the current window, while quit() ends the whole browser session.
Limitations of Traditional Headless Testing
Infrastructure Management Overhead
Browser and environment setup
Selenium Manager now handles drivers automatically, which removes one of the oldest headaches. But every CI agent still needs the right browsers installed and kept up to date, along with system dependencies and network access to your test environments. With several agents, that work multiplies.
Cross-platform differences
Headless behaviour can differ slightly between Linux, Windows and macOS, especially with fonts and rendering, so some tests need platform-specific handling.
Limited Parallel Capacity
Single machine limits
Headless browsers are lighter, but not free. A typical CI agent can only run a handful of browsers in parallel before memory or CPU runs out.
Scaling complexity
Going further means adding agents, spreading tests across them, and collecting the results back together, which is infrastructure your team has to build and maintain.
Narrow Browser Coverage
The Chromium default
Most teams default to headless Chrome because it's fast and easy. Real users on Firefox and Safari hit bugs that headless Chrome never shows.
No Safari, no mobile
Safari has no headless mode, and headless desktop browsers don't test mobile behaviour like touch events, mobile layouts or device-specific features.
Harder Debugging
No visual feedback
When a headless test fails, you can't watch what happened. You're working from logs, screenshots and DOM snapshots.
Awkward inspection tools
DevTools is still available, but it's much harder to use against a browser you can't see.
Going Beyond Local Headless Infrastructure
Managed Cloud Execution Grids
Cloud testing platforms remove the need to run your own browsers. They provide managed browser and device environments that your tests connect to over the internet.
Architecture Shift:
Local headless:
Test code → Local WebDriver → Browsers installed on your CI agent
→ Limited by that machine's capacity
Cloud execution:
Test code → Cloud platform → Managed browsers and devices
→ Scales with the platform, not your hardware
Local headless:
Test code → Local WebDriver → Browsers installed on your CI agent
→ Limited by that machine's capacity
Cloud execution:
Test code → Cloud platform → Managed browsers and devices
→ Scales with the platform, not your hardware
Local headless:
Test code → Local WebDriver → Browsers installed on your CI agent
→ Limited by that machine's capacity
Cloud execution:
Test code → Cloud platform → Managed browsers and devices
→ Scales with the platform, not your hardware
Local headless:
Test code → Local WebDriver → Browsers installed on your CI agent
→ Limited by that machine's capacity
Cloud execution:
Test code → Cloud platform → Managed browsers and devices
→ Scales with the platform, not your hardware
For framework-based suites, switching to a cloud grid is often a one-line change, pointing webdriver.Remote at the provider's endpoint:
from selenium import webdriver
options = webdriver.ChromeOptions()
options.set_capability("platformName", "Windows 11")
driver = webdriver.Remote(
command_executor="https://<your-cloud-grid-endpoint>/wd/hub",
options=options
)from selenium import webdriver
options = webdriver.ChromeOptions()
options.set_capability("platformName", "Windows 11")
driver = webdriver.Remote(
command_executor="https://<your-cloud-grid-endpoint>/wd/hub",
options=options
)from selenium import webdriver
options = webdriver.ChromeOptions()
options.set_capability("platformName", "Windows 11")
driver = webdriver.Remote(
command_executor="https://<your-cloud-grid-endpoint>/wd/hub",
options=options
)from selenium import webdriver
options = webdriver.ChromeOptions()
options.set_capability("platformName", "Windows 11")
driver = webdriver.Remote(
command_executor="https://<your-cloud-grid-endpoint>/wd/hub",
options=options
)Key Advantages:
No browser setup: no installing, updating or configuring browsers on CI agents
Scalable parallel runs: many tests at once, without provisioning more servers
Real cross-browser coverage: Chrome, Firefox, Edge and Safari across operating systems
Real devices: mobile browsers, tablets and responsive layouts on actual hardware
CI/CD Integration Without Infrastructure
Cloud-native testing platforms connect to your pipeline through official integrations, rather than running browsers on your CI agents. Virtuoso QA, for example, integrates with Jenkins, Azure DevOps and GitHub Actions, and runs tests on a cloud grid covering 2,000+ browser, OS and device combinations.
Supported Integrations:
Jenkins (native plugin)
Azure DevOps (marketplace extension)
GitHub Actions (official action)
GitLab CI (Docker image)
CircleCI (orb)
Bamboo (add-on)
Configuration Example:
name: Run Cloud Test Suite
on: [push]
jobs:
cloud-tests:
runs-on: ubuntu-latest
steps:
- name: Trigger test suite on cloud platform
run: |
curl -X POST "https://<platform-api>/executions" \
-H "Authorization: Bearer ${{ secrets.TEST_PLATFORM_TOKEN }}" \
-d '{"suite": "regression", "browsers": ["chrome", "firefox", "edge"]}'
name: Run Cloud Test Suite
on: [push]
jobs:
cloud-tests:
runs-on: ubuntu-latest
steps:
- name: Trigger test suite on cloud platform
run: |
curl -X POST "https://<platform-api>/executions" \
-H "Authorization: Bearer ${{ secrets.TEST_PLATFORM_TOKEN }}" \
-d '{"suite": "regression", "browsers": ["chrome", "firefox", "edge"]}'
name: Run Cloud Test Suite
on: [push]
jobs:
cloud-tests:
runs-on: ubuntu-latest
steps:
- name: Trigger test suite on cloud platform
run: |
curl -X POST "https://<platform-api>/executions" \
-H "Authorization: Bearer ${{ secrets.TEST_PLATFORM_TOKEN }}" \
-d '{"suite": "regression", "browsers": ["chrome", "firefox", "edge"]}'
name: Run Cloud Test Suite
on: [push]
jobs:
cloud-tests:
runs-on: ubuntu-latest
steps:
- name: Trigger test suite on cloud platform
run: |
curl -X POST "https://<platform-api>/executions" \
-H "Authorization: Bearer ${{ secrets.TEST_PLATFORM_TOKEN }}" \
-d '{"suite": "regression", "browsers": ["chrome", "firefox", "edge"]}'There's no browser installation or infrastructure to manage on the CI side. The pipeline just asks the platform to run the suite and waits for the result.
Natural Language Tests That Hide Browser Details
Cloud native platforms with natural language authoring abstract away browser-specific configuration entirely:
Traditional Headless Selenium:
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com/login")
WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.ID, "email"))
).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com/login")
WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.ID, "email"))
).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com/login")
WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.ID, "email"))
).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
driver = webdriver.Chrome(options=options)
driver.get("https://example.com/login")
WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.ID, "email"))
).send_keys("jane@example.com")
driver.find_element(By.ID, "password").send_keys("S3cure!pass")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()Natural language equivalent:
Navigate to "https://example.com/login"
Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page
Navigate to "https://example.com/login"
Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page
Navigate to "https://example.com/login"
Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page
Navigate to "https://example.com/login"
Write "jane@example.com" in the "Email" field
Write "S3cure!pass" in the "Password" field
Click "Sign in"
Look for "Welcome back" on the page
The platform handles:
Browser selection and configuration
Running tests in the cloud for CI/CD
Watching runs visually when you need to debug
Running the same test across several browsers
Running tests in parallel
Self-Healing That Reduces Test Brittleness
Headless or not, traditional Selenium tests break when the application changes. Self-healing platforms reduce that by finding elements through several signals rather than one locator:
Multiple identification strategies: labels, text, attributes and position, not a single selector
Automatic waiting: no hand-written wait code for each step
Detailed element models: structure, context and visual cues combined
Reviewable repairs: in Virtuoso QA, every heal is logged, and your team can accept or reject it, so fixes happen fast without anything changing silently
Every run also produces release evidence, recording what was tested and what was repaired. That's useful when someone asks what the pipeline actually proved before a release went out.
Best Practices for Cloud Native Testing
Design for Parallel Execution
Structure your suite so tests can run in any order, at the same time.
Anti-Pattern:
def test_create_order(driver):
...
def test_cancel_order(driver):
...
def test_create_order(driver):
...
def test_cancel_order(driver):
...
def test_create_order(driver):
...
def test_cancel_order(driver):
...
def test_create_order(driver):
...
def test_cancel_order(driver):
... Best Practice:
import pytest
@pytest.fixture
def new_order(api_client):
order = api_client.create_order()
yield order
api_client.delete_order(order["id"])
def test_cancel_order(driver, new_order):
driver.get(f"https://example.com/orders/{new_order['id']}")
... import pytest
@pytest.fixture
def new_order(api_client):
order = api_client.create_order()
yield order
api_client.delete_order(order["id"])
def test_cancel_order(driver, new_order):
driver.get(f"https://example.com/orders/{new_order['id']}")
... import pytest
@pytest.fixture
def new_order(api_client):
order = api_client.create_order()
yield order
api_client.delete_order(order["id"])
def test_cancel_order(driver, new_order):
driver.get(f"https://example.com/orders/{new_order['id']}")
... import pytest
@pytest.fixture
def new_order(api_client):
order = api_client.create_order()
yield order
api_client.delete_order(order["id"])
def test_cancel_order(driver, new_order):
driver.get(f"https://example.com/orders/{new_order['id']}")
... Independent tests like these can run in parallel safely, locally with pytest -n auto from pytest-xdist, or across a cloud grid.
2. Leverage Built-In Integrations
Prefer your platform's official CI/CD integration over custom scripts.
Avoid:
EXEC_ID=$(curl -s -X POST "$API/executions" | jq -r '.id')
while true; do
STATUS=$(curl -s "$API/executions/$EXEC_ID" | jq -r '.status')
[ "$STATUS" != "running" ] && break
sleep 30
done
EXEC_ID=$(curl -s -X POST "$API/executions" | jq -r '.id')
while true; do
STATUS=$(curl -s "$API/executions/$EXEC_ID" | jq -r '.status')
[ "$STATUS" != "running" ] && break
sleep 30
done
EXEC_ID=$(curl -s -X POST "$API/executions" | jq -r '.id')
while true; do
STATUS=$(curl -s "$API/executions/$EXEC_ID" | jq -r '.status')
[ "$STATUS" != "running" ] && break
sleep 30
done
EXEC_ID=$(curl -s -X POST "$API/executions" | jq -r '.id')
while true; do
STATUS=$(curl -s "$API/executions/$EXEC_ID" | jq -r '.status')
[ "$STATUS" != "running" ] && break
sleep 30
done
Prefer:
- task: <PlatformOfficialTask>@1
inputs:
suite: 'regression'
failPipelineOnTestFailure: true
- task: <PlatformOfficialTask>@1
inputs:
suite: 'regression'
failPipelineOnTestFailure: true
- task: <PlatformOfficialTask>@1
inputs:
suite: 'regression'
failPipelineOnTestFailure: true
- task: <PlatformOfficialTask>@1
inputs:
suite: 'regression'
failPipelineOnTestFailure: trueOfficial integrations give you better error handling and result reporting, and someone else maintains them.
3. Monitor Execution Analytics
Most cloud platforms report on:
Execution trends over time
Flaky tests that pass and fail without code changes
Slow tests and bottlenecks
Browser-specific failures
How your execution capacity is being used
Review these regularly and use them to tidy up the suite.
Related Reads