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. Use chromedriver --version and google-chrome --version to 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 Response

The 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.

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 MethodGo ConstantSpeedStabilityWhen to Use
IDselenium.ByIDVery FastVery HighFirst choice if the element has a unique id attribute.
Nameselenium.ByNameFastHighSuitable for form elements (like input, textarea).
CSS Selectorselenium.ByCSSSelectorFastHighBest for classes, custom attributes (data-testid), or nested elements.
XPathselenium.ByXPATHSlowerLowOnly use for partial text searches or up/down DOM tree navigation.
Link Textselenium.ByLinkTextMediumMediumFinding <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)
// 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.

// 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 ✓
    end

JavaScript 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 --> G

Running 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.Sleep with a fixed duration is unreliable and slows down the test suite.
  • Selector strategy — prioritize ByID > CSS selectors with data-testid > generic CSS selectors > XPath; add data-testid attributes 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.
  • ExecuteScript as 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, and SwitchFrame(nil) to return to the main page.
  • Headless in CI — use the --headless --no-sandbox --disable-dev-shm-usage flags 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
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact