Selenium #
Selenium is a browser automation library that lets you control a browser programmatically — clicking buttons, filling forms, waiting for elements to appear, taking screenshots, and even extracting data from JavaScript-rendered pages. In Go, Selenium is accessed through the github.com/tebeka/selenium binding, which implements the W3C WebDriver protocol. Selenium is useful for two main needs: end-to-end testing (verifying that a web application works correctly from the user’s perspective) and web scraping of sites whose content is rendered by JavaScript, so it can’t be fetched with a plain HTTP request. This article covers the full installation, all basic operations, strategies for handling dynamic elements, and recommended patterns for a stable production test suite.
Installation #
Go Dependencies #
go get github.com/tebeka/selenium
go get github.com/tebeka/selenium/chrome
WebDriver and Browser #
Selenium doesn’t control the browser directly — it talks to the WebDriver, which then controls the browser. You need to install both, matching your browser:
ChromeDriver (Chrome/Chromium):
# macOS
brew install chromedriver
# Ubuntu / Debian
apt-get install chromium-chromedriver
# Or download manually from: https://chromedriver.chromium.org/downloads
# Make sure the ChromeDriver version matches your installed Chrome version
chromedriver --version
google-chrome --version
GeckoDriver (Firefox):
# macOS
brew install geckodriver
# Download manually from: https://github.com/mozilla/geckodriver/releases
The ChromeDriver version must match the Chrome version installed on the system. Version mismatches are the most common cause of Selenium failures during initial setup. Usechromedriver --versionandgoogle-chrome --versionto verify.
Selenium WebDriver Architecture #
Before writing code, it’s important to understand how the Selenium components communicate with each other:
sequenceDiagram
participant GoApp as Go Code (Application)
participant Driver as WebDriver (ChromeDriver)
participant Browser as Browser Engine (Chrome)
participant DOM as Web Page (DOM)
GoApp->>Driver: Send HTTP Command (W3C WebDriver)
Driver->>Browser: Translate to Internal Browser API
Browser->>DOM: Query/Modify DOM Elements
DOM-->>Browser: Return Status/Data
Browser-->>Driver: Send Response
Driver-->>GoApp: Send JSON ResponseThe Go code sends commands as HTTP requests to the WebDriver server (ChromeDriver/GeckoDriver). The WebDriver then translates those commands into the browser’s internal protocol. This means the WebDriver must be running as a separate process before your Go program executes.
Basic Setup #
There are two ways to run the WebDriver: managing it manually (you start it yourself) or letting the library manage it.
Method 1: The Library Manages the WebDriver (Recommended for Testing) #
package main
import (
"fmt"
"log"
"time"
"github.com/tebeka/selenium"
"github.com/tebeka/selenium/chrome"
)
func main() {
// ChromeDriver configuration
opts := []selenium.ServiceOption{
selenium.Output(nil), // direct the output to /dev/null
}
// Start ChromeDriver as a service
service, err := selenium.NewChromeDriverService("chromedriver", 4444, opts...)
if err != nil {
log.Fatalf("failed to start ChromeDriver: %v", err)
}
defer service.Stop()
// Chrome configuration
caps := selenium.Capabilities{
"browserName": "chrome",
}
chromeCaps := chrome.Capabilities{
Args: []string{
"--headless", // run without a UI (headless mode)
"--no-sandbox", // required in Docker/CI
"--disable-dev-shm-usage", // reduces crashes in containers
"--window-size=1920,1080",
},
}
caps.AddChrome(chromeCaps)
// Create a WebDriver session
driver, err := selenium.NewRemote(caps, "http://localhost:4444/wd/hub")
if err != nil {
log.Fatalf("failed to create a WebDriver session: %v", err)
}
defer driver.Quit()
// Open a URL
if err := driver.Get("https://example.com"); err != nil {
log.Fatalf("failed to open the URL: %v", err)
}
title, _ := driver.Title()
fmt.Println("Page title:", title)
}
Method 2: Connecting to an Already-Running WebDriver #
// Run ChromeDriver manually in the terminal:
// chromedriver --port=9515
caps := selenium.Capabilities{"browserName": "chrome"}
driver, err := selenium.NewRemote(caps, "http://localhost:9515/wd/hub")
if err != nil {
log.Fatal(err)
}
defer driver.Quit()
Browser Navigation #
Once the session is created, you can fully control browser navigation:
// Open a URL
driver.Get("https://example.com")
// Navigate forward / backward
driver.Back()
driver.Forward()
driver.Refresh()
// Get current page info
url, _ := driver.CurrentURL()
title, _ := driver.Title()
source, _ := driver.PageSource() // the full page HTML
fmt.Printf("URL : %s\n", url)
fmt.Printf("Title : %s\n", title)
// Set the window size
driver.ResizeTo(1280, 800)
// Maximize / minimize
driver.MaximizeWindow("")
Finding Elements #
Finding elements on the page is the most frequent operation. Selenium provides various search strategies:
// Find one element — returns an error if not found
elem, err := driver.FindElement(selenium.ByID, "username")
elem, err := driver.FindElement(selenium.ByName, "email")
elem, err := driver.FindElement(selenium.ByClassName, "btn-primary")
elem, err := driver.FindElement(selenium.ByCSSSelector, "#login-form .submit-btn")
elem, err := driver.FindElement(selenium.ByXPATH, "//button[@type='submit']")
elem, err := driver.FindElement(selenium.ByLinkText, "Login")
elem, err := driver.FindElement(selenium.ByPartialLinkText, "Log")
elem, err := driver.FindElement(selenium.ByTagName, "h1")
// Find many elements — returns a slice (empty if none)
elems, err := driver.FindElements(selenium.ByCSSSelector, "table tbody tr")
for _, row := range elems {
text, _ := row.Text()
fmt.Println(text)
}
// Find elements inside another element (scoped search)
table, _ := driver.FindElement(selenium.ByID, "results-table")
rows, _ := table.FindElements(selenium.ByTagName, "tr")
Locator Strategies #
Here’s a comparison of the selector strategies supported by Selenium in Go to help you choose the most optimal search method:
| Selector Method | Go Constant | Speed | Stability | When to Use |
|---|---|---|---|---|
| ID | selenium.ByID | Very Fast | Very High | First choice if the element has a unique id attribute. |
| Name | selenium.ByName | Fast | High | Suitable for form elements (like input, textarea). |
| CSS Selector | selenium.ByCSSSelector | Fast | High | Best for classes, custom attributes (data-testid), or nested elements. |
| XPath | selenium.ByXPATH | Slower | Low | Only use for partial text searches or up/down DOM tree navigation. |
| Link Text | selenium.ByLinkText | Medium | Medium | Finding <a> tags by their exact text. |
Which Search Strategy to Use? #
flowchart TD
A{Does the element\nhave a unique ID?} -- Yes --> B["ByID\n#unique-id\n← fastest & most stable"]
A -- No --> C{Has a\ndata-testid / aria-label\nattribute?}
C -- Yes --> D["ByCSSSelector\n[data-testid='btn-submit']\n← recommended for testing"]
C -- No --> E{Has a\nunique class?}
E -- Yes --> F["ByCSSSelector\n.unique-class\n← easy to read"]
E -- No --> G{Need complex\nDOM traversal?}
G -- Yes --> H["ByXPATH\n//div[@class='parent']/button\n← flexible but fragile"]
G -- No --> I["ByTagName / ByLinkText\nfor semantic elements"]For test automation, add data-testid attributes to key elements in your application. This makes selectors more stable because they don’t depend on CSS classes or DOM structures that often change when styling is modified.Interacting with Elements #
Once an element is found, you can perform various interactions:
Text Input and Clicks #
// Fill a text input
input, _ := driver.FindElement(selenium.ByID, "username")
input.Clear() // clear first if there's old text
input.SendKeys("[email protected]")
// Click an element
btn, _ := driver.FindElement(selenium.ByCSSSelector, "button[type='submit']")
btn.Click()
// Get text from an element
heading, _ := driver.FindElement(selenium.ByTagName, "h1")
text, _ := heading.Text()
fmt.Println("Heading:", text)
// Read attributes
link, _ := driver.FindElement(selenium.ByCSSSelector, "a.profile-link")
href, _ := link.GetAttribute("href")
class, _ := link.GetAttribute("class")
// Check whether the element is displayed / enabled
displayed, _ := btn.IsDisplayed()
enabled, _ := btn.IsEnabled()
selected, _ := btn.IsSelected() // for checkboxes / radios
Special Keys #
import "github.com/tebeka/selenium/keys"
// Press Enter
input.SendKeys(keys.Enter)
// Press Tab to move between fields
input.SendKeys(keys.Tab)
// Combination Ctrl+A (select all)
input.SendKeys(keys.Control + "a")
// Escape
input.SendKeys(keys.Escape)
Dropdowns (Select) #
// Selenium doesn't have a dedicated <select> helper in the Go binding
// Use the FindElement + Click approach
selectElem, _ := driver.FindElement(selenium.ByID, "category")
// Click an option by its value
option, _ := selectElem.FindElement(
selenium.ByCSSSelector, "option[value='electronics']")
option.Click()
// Or via JavaScript for direct selection
driver.ExecuteScript(
`document.getElementById('category').value = 'electronics'`, nil)
Waiting for Elements #
This is the most critical part of Selenium. Modern browsers render content asynchronously — elements may not be in the DOM when you look for them.
Explicit Waits (Recommended) #
// ANTI-PATTERN: time.Sleep — unreliable and slow
time.Sleep(3 * time.Second) // ✗ can be too long or too short
elem, _ := driver.FindElement(selenium.ByID, "result")
// CORRECT: wait until a specific condition is met
func waitForElement(driver selenium.WebDriver, by, value string, timeout time.Duration) (selenium.WebElement, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
elem, err := driver.FindElement(by, value)
if err == nil {
displayed, _ := elem.IsDisplayed()
if displayed {
return elem, nil
}
}
time.Sleep(300 * time.Millisecond) // polling interval
}
return nil, fmt.Errorf("element %s=%q did not appear within %v", by, value, timeout)
}
// Usage
result, err := waitForElement(driver, selenium.ByID, "search-results", 10*time.Second)
if err != nil {
log.Fatal("timed out waiting for search results:", err)
}
Waiting for Various Conditions #
// Wait for the URL to change (e.g. after a login redirect)
func waitForURL(driver selenium.WebDriver, expectedURL string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
current, _ := driver.CurrentURL()
if current == expectedURL {
return nil
}
time.Sleep(300 * time.Millisecond)
}
return fmt.Errorf("URL did not change to %s within %v", expectedURL, timeout)
}
// Wait for text to appear in an element
func waitForText(driver selenium.WebDriver, by, value, expectedText string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
elem, err := driver.FindElement(by, value)
if err == nil {
text, _ := elem.Text()
if strings.Contains(text, expectedText) {
return nil
}
}
time.Sleep(300 * time.Millisecond)
}
return fmt.Errorf("text %q did not appear within %v", expectedText, timeout)
}
// Wait for an element to disappear (e.g. a loading spinner)
func waitForElementGone(driver selenium.WebDriver, by, value string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
elem, err := driver.FindElement(by, value)
if err != nil {
return nil // element not found = already gone
}
displayed, _ := elem.IsDisplayed()
if !displayed {
return nil
}
time.Sleep(300 * time.Millisecond)
}
return fmt.Errorf("element %s=%q still exists after %v", by, value, timeout)
}
sequenceDiagram
participant Go as Go Code
participant WD as WebDriver
participant DOM
Go->>WD: FindElement("result")
WD->>DOM: Find #result
DOM-->>WD: Not found (JS still loading)
WD-->>Go: error
loop Every 300ms until the timeout
Go->>Go: time.Sleep(300ms)
Go->>WD: FindElement("result")
WD->>DOM: Find #result
DOM-->>WD: Element found
WD-->>Go: WebElement ✓
endJavaScript Execution #
For interactions that can’t be done through the standard WebDriver API, use ExecuteScript:
// Scroll to the bottom of the page
driver.ExecuteScript("window.scrollTo(0, document.body.scrollHeight)", nil)
// Scroll to a specific element
elem, _ := driver.FindElement(selenium.ByID, "target-section")
driver.ExecuteScript("arguments[0].scrollIntoView(true)", []interface{}{elem})
// Click via JavaScript (useful if the element is covered by another element)
driver.ExecuteScript("arguments[0].click()", []interface{}{elem})
// Fill an input that can't be set via SendKeys (React controlled inputs)
driver.ExecuteScript(
`arguments[0].value = arguments[1];
arguments[0].dispatchEvent(new Event('input', { bubbles: true }));
arguments[0].dispatchEvent(new Event('change', { bubbles: true }));`,
[]interface{}{elem, "new value"},
)
// Read a value from the page
result, _ := driver.ExecuteScript("return document.title", nil)
title := result.(string)
// Remove an attribute (e.g. readonly)
driver.ExecuteScript("arguments[0].removeAttribute('readonly')", []interface{}{elem})
Screenshots #
// Full-page screenshot
screenshot, err := driver.Screenshot()
if err != nil {
log.Fatal("failed to take a screenshot:", err)
}
if err := os.WriteFile("screenshot.png", screenshot, 0644); err != nil {
log.Fatal("failed to save the screenshot:", err)
}
// Screenshot of a specific element
elem, _ := driver.FindElement(selenium.ByID, "chart-container")
elemScreenshot, _ := elem.Screenshot(false)
os.WriteFile("chart.png", elemScreenshot, 0644)
Handling Popups and Alerts #
// Accept (OK) an alert
alert, err := driver.AlertText()
if err == nil {
fmt.Println("Alert:", alert)
driver.AcceptAlert()
}
// Dismiss (Cancel) a confirm dialog
driver.DismissAlert()
// Fill text into a prompt dialog
driver.AlertText() // read the prompt text
driver.SetAlertText("text to fill") // fill the text
driver.AcceptAlert() // press OK
Handling Iframes #
Content inside an <iframe> can’t be accessed directly — you must switch contexts first:
// Switch to an iframe by index
driver.SwitchFrame(0) // the first iframe on the page
// Switch to an iframe by name or ID
driver.SwitchFrame("payment-iframe")
// Switch to an iframe by a WebElement
iframe, _ := driver.FindElement(selenium.ByCSSSelector, "iframe.content-frame")
driver.SwitchFrame(iframe)
// Interactions inside the iframe — now accessible
innerBtn, _ := driver.FindElement(selenium.ByID, "submit-inside-iframe")
innerBtn.Click()
// Return to the main page context
driver.SwitchFrame(nil)
// Return to the parent frame (for nested iframes)
driver.SwitchToParentFrame()
Multiple Windows and Tabs #
// Save the current window handle
mainWindow, _ := driver.CurrentWindowHandle()
// Click a link that opens a new tab
link, _ := driver.FindElement(selenium.ByCSSSelector, "a[target='_blank']")
link.Click()
// Get all window handles
handles, _ := driver.WindowHandles()
// Switch to the new tab (the last handle)
for _, handle := range handles {
if handle != mainWindow {
driver.SwitchWindow(handle)
break
}
}
// Do something in the new tab
url, _ := driver.CurrentURL()
fmt.Println("New tab URL:", url)
// Close this tab and return to the main tab
driver.Close()
driver.SwitchWindow(mainWindow)
Web Scraping with Selenium #
Selenium is suitable for scraping sites whose content is rendered via JavaScript. Here’s a structured scraping example:
type Product struct {
Name string
Price string
URL string
}
func scrapeProducts(driver selenium.WebDriver, baseURL string) ([]Product, error) {
var products []Product
page := 1
for {
url := fmt.Sprintf("%s?page=%d", baseURL, page)
if err := driver.Get(url); err != nil {
return nil, fmt.Errorf("failed to open page %d: %w", page, err)
}
// Wait for the content to load
_, err := waitForElement(driver, selenium.ByCSSSelector, ".product-card", 10*time.Second)
if err != nil {
break // no more products, done
}
// Get all product cards on this page
cards, _ := driver.FindElements(selenium.ByCSSSelector, ".product-card")
if len(cards) == 0 {
break
}
for _, card := range cards {
nameElem, err := card.FindElement(selenium.ByCSSSelector, ".product-name")
if err != nil {
continue
}
name, _ := nameElem.Text()
priceElem, err := card.FindElement(selenium.ByCSSSelector, ".product-price")
if err != nil {
continue
}
price, _ := priceElem.Text()
linkElem, err := card.FindElement(selenium.ByTagName, "a")
if err != nil {
continue
}
href, _ := linkElem.GetAttribute("href")
products = append(products, Product{
Name: name,
Price: price,
URL: href,
})
}
// Check whether there's a next page
nextBtn, err := driver.FindElement(selenium.ByCSSSelector, ".pagination .next:not(.disabled)")
if err != nil {
break // no next button, done
}
nextBtn.Click()
page++
// A small pause so we don't overload the server
time.Sleep(500 * time.Millisecond)
}
return products, nil
}
The Page Object Model (POM) Pattern #
For large test suites, avoid writing selectors and interactions directly in tests. Use the Page Object Model — each page is represented by a struct that encapsulates all interactions:
// ANTI-PATTERN: selectors scattered across all tests
func TestLogin(t *testing.T) {
driver.FindElement(selenium.ByID, "username").SendKeys("[email protected]")
driver.FindElement(selenium.ByID, "password").SendKeys("secret")
driver.FindElement(selenium.ByCSSSelector, "button[type='submit']").Click()
// If the ID "username" changes, all tests using it must be updated
}
// CORRECT: Page Object Model — selector changes only in one place
type LoginPage struct {
driver selenium.WebDriver
}
func NewLoginPage(driver selenium.WebDriver) *LoginPage {
return &LoginPage{driver: driver}
}
func (p *LoginPage) Open() error {
return p.driver.Get("https://example.com/login")
}
func (p *LoginPage) FillUsername(username string) error {
elem, err := p.driver.FindElement(selenium.ByID, "username")
if err != nil {
return err
}
elem.Clear()
return elem.SendKeys(username)
}
func (p *LoginPage) FillPassword(password string) error {
elem, err := p.driver.FindElement(selenium.ByID, "password")
if err != nil {
return err
}
elem.Clear()
return elem.SendKeys(password)
}
func (p *LoginPage) Submit() error {
btn, err := p.driver.FindElement(selenium.ByCSSSelector, "button[type='submit']")
if err != nil {
return err
}
return btn.Click()
}
func (p *LoginPage) Login(username, password string) error {
if err := p.FillUsername(username); err != nil {
return err
}
if err := p.FillPassword(password); err != nil {
return err
}
return p.Submit()
}
// In the test — clean and easy to read
func TestSuccessfulLogin(t *testing.T) {
loginPage := NewLoginPage(driver)
loginPage.Open()
err := loginPage.Login("[email protected]", "secret")
if err != nil {
t.Fatal("login failed:", err)
}
waitForURL(driver, "https://example.com/dashboard", 5*time.Second)
}
graph TD
subgraph "Test Files"
A["TestLogin"]
B["TestCheckout"]
C["TestSearch"]
end
subgraph "Page Objects"
D["LoginPage\n- Open()\n- Login(user, pass)\n- FillUsername()\n- FillPassword()"]
E["CheckoutPage\n- AddToCart()\n- Checkout()\n- FillAddress()"]
F["SearchPage\n- Search(query)\n- GetResults()"]
end
subgraph "WebDriver"
G["selenium.WebDriver\nFindElement, Click,\nSendKeys, etc."]
end
A --> D
B --> E
C --> F
D --> G
E --> G
F --> GRunning in CI/CD (Docker) #
To run Selenium tests in a CI/CD pipeline, use Selenium Grid via Docker:
# docker-compose.yml
version: "3.8"
services:
selenium-hub:
image: selenium/hub:4.18
ports:
- "4442:4442"
- "4443:4443"
- "4444:4444"
chrome:
image: selenium/node-chrome:4.18
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
# Start the Selenium Grid
docker-compose up -d
# Run the Go tests connected to the Grid
SE_URL=http://localhost:4444/wd/hub go test ./tests/...
// Connecting to the Selenium Grid in CI
func newDriver() (selenium.WebDriver, error) {
gridURL := os.Getenv("SE_URL")
if gridURL == "" {
gridURL = "http://localhost:4444/wd/hub"
}
caps := selenium.Capabilities{"browserName": "chrome"}
chromeCaps := chrome.Capabilities{
Args: []string{
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
},
}
caps.AddChrome(chromeCaps)
return selenium.NewRemote(caps, gridURL)
}
When Not to Use Selenium #
Use Selenium if:
✓ The target site renders content via JavaScript (SPA, React, Vue)
✓ You need complex UI interactions (clicks, drags, hovers, iframes)
✓ End-to-end testing that simulates real user behavior
✓ You need screenshots or visual regression testing
Consider net/http + goquery (static HTML scraping) if:
✗ The target site renders HTML on the server (content is already in the HTTP response)
✗ Performance is a priority — Selenium is far slower than a plain HTTP request
✗ You need large-scale scraping (hundreds of pages per minute)
Consider chromedp (direct Chrome DevTools Protocol) if:
✗ You need lower-level control of Chrome without the WebDriver layer
✗ You want lighter dependencies (chromedp doesn't need a separate ChromeDriver)
✗ Headless Chrome performance is a priority
Summary #
- Three-layer architecture — Go code talks to ChromeDriver/GeckoDriver over HTTP, and the WebDriver then controls the browser; both must be running before the Go program executes.
- Versions must match — always keep the ChromeDriver version in sync with the installed Chrome version; mismatches are the most common source of errors.
- Explicit waits, not
time.Sleep— create wait functions with a polling loop that waits for a specific condition;time.Sleepwith a fixed duration is unreliable and slows down the test suite.- Selector strategy — prioritize
ByID> CSS selectors withdata-testid> generic CSS selectors > XPath; adddata-testidattributes to key elements in your application to make tests more stable.- Page Object Model — encapsulate each page’s selectors and interactions into a struct; UI changes only need to be updated in one place, not across the whole test suite.
ExecuteScriptas an escape hatch — use it for interactions that can’t be done through the WebDriver API: scrolling, clicking hidden elements, filling React controlled inputs.- Iframes need a frame switch — always
SwitchFrame(iframe)before interacting with content inside an iframe, andSwitchFrame(nil)to return to the main page.- Headless in CI — use the
--headless --no-sandbox --disable-dev-shm-usageflags when running in Docker or a CI pipeline; Selenium Grid via Docker is the most reliable way to set up CI.
Next: Articles & Resources →
← Previous: Libraries