Selenium RS #

Browser automation in Rust uses the thirtyfour crate — an async, ergonomic implementation of the WebDriver protocol. The name “Selenium RS” on this site refers to the Rust browser automation ecosystem in general, with thirtyfour as the main choice thanks to its clean API and native async support. Browser automation serves two main purposes: end-to-end testing (verifying web applications from a real user’s perspective) and web scraping (extracting data from pages that need JavaScript to render content). This article covers setup, all important operations, idiomatic testing patterns, and headless mode considerations for CI/CD.

WebDriver Architecture #

sequenceDiagram
    participant R as Rust Program\n(thirtyfour)
    participant D as WebDriver\n(ChromeDriver/GeckoDriver)
    participant B as Browser\n(Chrome/Firefox)

    R->>D: HTTP: POST /session (capabilities)
    D->>B: Launch browser
    B->>D: Browser ready
    D->>R: session_id

    R->>D: HTTP: POST /session/{id}/url
    D->>B: Navigate to URL
    B->>D: Page loaded

    R->>D: HTTP: POST /session/{id}/element
    D->>B: Find element
    B->>D: element_id

    R->>D: HTTP: POST /element/{id}/click
    D->>B: Click element
    B->>D: Done

WebDriver is a W3C standard — you write code once and run it on Chrome, Firefox, Edge, or Safari by just switching the driver.


Prerequisites and Installation #

Before running the code, make sure a WebDriver is installed:

# Chrome — download ChromeDriver matching your Chrome version
# https://chromedriver.chromium.org/downloads
# or use chromedriver-autoinstall

# Firefox — GeckoDriver
# https://github.com/mozilla/geckodriver/releases

# Run the WebDriver in a separate terminal
chromedriver --port=4444
# or
geckodriver --port=4444
[dependencies]
thirtyfour = "0.32"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Connections and Basic Navigation #

use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> WebDriverResult<()> {
    // Setup capabilities
    let mut caps = DesiredCapabilities::chrome();

    // Headless mode — no browser window (for servers/CI)
    caps.set_headless()?;
    caps.add_chrome_arg("--no-sandbox")?;
    caps.add_chrome_arg("--disable-dev-shm-usage")?;
    caps.add_chrome_arg("--window-size=1920,1080")?;

    // Connect to a running ChromeDriver
    let driver = WebDriver::new("http://localhost:4444", caps).await?;

    // Navigate to a URL
    driver.goto("https://www.contoh.com").await?;
    println!("URL: {}", driver.current_url().await?);
    println!("Title: {}", driver.title().await?);

    // Navigate back/forward
    driver.back().await?;
    driver.forward().await?;

    // Refresh the page
    driver.refresh().await?;

    // Get the full page HTML
    let source = driver.source().await?;
    println!("HTML length: {} characters", source.len());

    // Close the browser — IMPORTANT: always close at the end
    driver.quit().await?;
    Ok(())
}

Finding Elements #

use thirtyfour::prelude::*;

async fn contoh_find(driver: &WebDriver) -> WebDriverResult<()> {
    driver.goto("https://www.contoh.com").await?;

    // By ID
    let elemen = driver.find(By::Id("tombol-utama")).await?;

    // By CSS selector — most flexible
    let judul = driver.find(By::Css("h1.judul-utama")).await?;
    let tombol = driver.find(By::Css("button[type='submit']")).await?;

    // By XPath — powerful but verbose
    let link = driver.find(By::XPath("//a[@href='/tentang']")).await?;

    // By tag name
    let semua_h2: Vec<WebElement> = driver.find_all(By::Tag("h2")).await?;
    println!("Number of H2s: {}", semua_h2.len());

    // By link text
    let link_kontak = driver.find(By::LinkText("Hubungi Kami")).await?;

    // By partial link text
    let link_partial = driver.find(By::PartialLinkText("Tentang")).await?;

    // Elements within elements (relative search)
    let form = driver.find(By::Id("form-login")).await?;
    let input_email = form.find(By::Name("email")).await?;

    // Check element existence without an error
    let mungkin_ada = driver.find(By::Id("mungkin-tidak-ada")).await;
    match mungkin_ada {
        Ok(el) => println!("Element exists: {}", el.id().await?.unwrap_or_default()),
        Err(_) => println!("Element not found"),
    }

    Ok(())
}

Interacting with Elements #

use thirtyfour::prelude::*;

async fn contoh_interaksi(driver: &WebDriver) -> WebDriverResult<()> {
    driver.goto("https://contoh.com/login").await?;

    // Click an element
    let tombol = driver.find(By::Css("button.buka-menu")).await?;
    tombol.click().await?;

    // Input text
    let email_input = driver.find(By::Id("email")).await?;
    email_input.clear().await?;           // clear it first
    email_input.send_keys("[email protected]").await?;

    let password_input = driver.find(By::Id("password")).await?;
    password_input.send_keys("rahasia123").await?;

    // Submit the form with Enter
    password_input.send_keys(Key::Return).await?;
    // or click the submit button
    driver.find(By::Css("button[type='submit']")).await?.click().await?;

    // Get text from an element
    let pesan = driver.find(By::Css(".pesan-sukses")).await?;
    println!("Message: {}", pesan.text().await?);

    // Get an attribute
    let link = driver.find(By::Css("a.link-utama")).await?;
    let href = link.attr("href").await?.unwrap_or_default();
    println!("Href: {}", href);

    // Get an input value
    let nilai_input: String = driver
        .find(By::Id("nama-depan"))
        .await?
        .attr("value")
        .await?
        .unwrap_or_default();

    // Check element status
    let tombol_hapus = driver.find(By::Id("tombol-hapus")).await?;
    println!("Enabled: {}", tombol_hapus.is_enabled().await?);
    println!("Displayed: {}", tombol_hapus.is_displayed().await?);
    println!("Selected: {}", tombol_hapus.is_selected().await?);

    Ok(())
}

use thirtyfour::prelude::*;

async fn contoh_form_lanjutan(driver: &WebDriver) -> WebDriverResult<()> {
    driver.goto("https://contoh.com/form").await?;

    // Checkbox — check the status and toggle
    let checkbox = driver.find(By::Id("setuju-syarat")).await?;
    if !checkbox.is_selected().await? {
        checkbox.click().await?;  // check it
    }
    println!("Checkbox checked: {}", checkbox.is_selected().await?);

    // Radio button
    let radio_ya = driver.find(By::Css("input[type='radio'][value='ya']")).await?;
    radio_ya.click().await?;

    // <select> dropdown
    let select_el = driver.find(By::Id("pilih-kota")).await?;

    // Select by visible text
    let select = thirtyfour::extensions::form::SelectElement::new(&select_el).await?;
    select.select_by_visible_text("Jakarta").await?;

    // Select by value attribute
    select.select_by_value("surabaya").await?;

    // Select by index
    select.select_by_index(2).await?;

    // Get the selected option
    let opsi_terpilih = select.first_selected_option().await?;
    println!("Selected: {}", opsi_terpilih.text().await?);

    // File upload
    let file_input = driver.find(By::Id("upload-foto")).await?;
    file_input.send_keys("/path/to/foto.jpg").await?;

    Ok(())
}

Wait Strategies — Waiting for Elements #

This is the most important aspect of stable browser automation. Never use a fixed sleep — use explicit waits:

use thirtyfour::prelude::*;
use std::time::Duration;

async fn contoh_wait(driver: &WebDriver) -> WebDriverResult<()> {
    driver.goto("https://contoh.com/async-load").await?;

    // ANTI-PATTERN: fixed sleep — fragile and slow
    // tokio::time::sleep(Duration::from_secs(3)).await;
    // let el = driver.find(By::Id("konten")).await?;

    // CORRECT: wait until — wait for a condition to be met
    // 10 second timeout, polling every 500ms
    let el = driver
        .query(By::Id("konten-dimuat"))
        .wait(Duration::from_secs(10), Duration::from_millis(500))
        .first()
        .await?;

    // Wait until an element is visible
    let loading = driver.find(By::Css(".loading-spinner")).await.ok();
    if let Some(spinner) = loading {
        // Wait until the spinner disappears
        spinner
            .wait_until()
            .displayed(false)
            .await?;
    }

    // Wait until text changes
    let status = driver.find(By::Id("status-proses")).await?;
    status
        .wait_until()
        .condition(Box::new(|el: &WebElement| {
            Box::pin(async move {
                let teks = el.text().await?;
                Ok(teks == "Selesai" || teks == "Error")
            })
        }))
        .await?;

    println!("Final status: {}", status.text().await?);
    Ok(())
}

Frames, Windows, and Tabs #

use thirtyfour::prelude::*;

async fn contoh_frame_window(driver: &WebDriver) -> WebDriverResult<()> {
    driver.goto("https://contoh.com").await?;

    // Working with iframes
    let iframe = driver.find(By::Id("embedded-content")).await?;
    driver.enter_frame(Some(&iframe)).await?;

    // Now you can access content inside the iframe
    let konten = driver.find(By::Css(".konten-iframe")).await?;
    println!("Iframe content: {}", konten.text().await?);

    // Return to the main frame
    driver.enter_parent_frame().await?;
    // or
    driver.enter_default_frame().await?;

    // Open a new tab
    driver.execute("window.open('https://contoh2.com', '_blank');", vec![]).await?;

    // List all windows/tabs
    let windows = driver.windows().await?;
    println!("Number of tabs: {}", windows.len());

    // Switch to the new tab (usually the last one)
    driver.switch_to_window(windows.last().unwrap().clone()).await?;
    println!("New tab URL: {}", driver.current_url().await?);

    // Back to the first tab
    driver.switch_to_window(windows.first().unwrap().clone()).await?;

    // Close the current tab
    driver.close_window().await?;

    Ok(())
}

Screenshots and JavaScript #

use thirtyfour::prelude::*;

async fn contoh_screenshot_js(driver: &WebDriver) -> WebDriverResult<()> {
    driver.goto("https://contoh.com").await?;

    // Full-page screenshot
    let screenshot = driver.screenshot_as_png().await?;
    std::fs::write("screenshot.png", &screenshot)?;
    println!("Screenshot saved: {} bytes", screenshot.len());

    // Screenshot of a single element
    let elemen = driver.find(By::Id("konten-utama")).await?;
    let el_screenshot = elemen.screenshot_as_png().await?;
    std::fs::write("elemen.png", &el_screenshot)?;

    // Execute JavaScript
    let title: serde_json::Value = driver
        .execute("return document.title;", vec![])
        .await?
        .json()?;
    println!("Title via JS: {}", title);

    // Scroll to an element
    let footer = driver.find(By::Tag("footer")).await?;
    driver
        .execute("arguments[0].scrollIntoView(true);", vec![footer.to_json()?])
        .await?;

    // Set an input value via JavaScript (for fields that are hard to interact with)
    let input = driver.find(By::Id("input-readonly")).await?;
    driver
        .execute(
            "arguments[0].value = arguments[1];",
            vec![input.to_json()?, serde_json::Value::String("nilai baru".to_string())],
        )
        .await?;

    // Click via JavaScript (useful if the element is covered by another)
    driver
        .execute("arguments[0].click();", vec![input.to_json()?])
        .await?;

    // Get localStorage
    let token: serde_json::Value = driver
        .execute("return localStorage.getItem('auth_token');", vec![])
        .await?
        .json()?;
    println!("Token: {}", token);

    Ok(())
}

End-to-End Testing #

use thirtyfour::prelude::*;
use std::time::Duration;

#[tokio::test]
async fn test_alur_login() -> WebDriverResult<()> {
    let mut caps = DesiredCapabilities::chrome();
    caps.set_headless()?;
    caps.add_chrome_arg("--no-sandbox")?;
    caps.add_chrome_arg("--disable-dev-shm-usage")?;

    let driver = WebDriver::new("http://localhost:4444", caps).await?;

    // Make sure the browser is closed even if the test fails
    let result = test_login_internal(&driver).await;
    driver.quit().await?;
    result
}

async fn test_login_internal(driver: &WebDriver) -> WebDriverResult<()> {
    driver.goto("http://localhost:3000/login").await?;

    // Fill in the login form
    driver.find(By::Id("email")).await?
        .send_keys("[email protected]").await?;
    driver.find(By::Id("password")).await?
        .send_keys("password123").await?;
    driver.find(By::Css("button[type='submit']")).await?
        .click().await?;

    // Wait for the redirect to the dashboard
    driver
        .query(By::Css(".dashboard-container"))
        .wait(Duration::from_secs(5), Duration::from_millis(200))
        .first()
        .await?;

    // Verify the redirect succeeded
    let url = driver.current_url().await?;
    assert!(url.contains("/dashboard"), "Should be on the dashboard, but: {}", url);

    // Verify elements exist on the page
    let pesan_sambutan = driver.find(By::Css(".sambutan-pengguna")).await?;
    assert!(pesan_sambutan.text().await?.contains("admin"),
        "The welcome message does not contain the username");

    Ok(())
}

// Page test
#[tokio::test]
async fn test_halaman_tidak_ditemukan() -> WebDriverResult<()> {
    let mut caps = DesiredCapabilities::chrome();
    caps.set_headless()?;
    let driver = WebDriver::new("http://localhost:4444", caps).await?;

    driver.goto("http://localhost:3000/halaman-tidak-ada").await?;

    let judul = driver.title().await?;
    assert_eq!(judul, "404 - Not Found");

    driver.quit().await?;
    Ok(())
}

Web Scraping #

use thirtyfour::prelude::*;

#[derive(Debug)]
struct HasilScraping {
    judul: String,
    harga: String,
    rating: Option<String>,
}

async fn scrape_produk(driver: &WebDriver, url: &str) -> WebDriverResult<Vec<HasilScraping>> {
    driver.goto(url).await?;

    // Scroll to the bottom to load lazy content
    for _ in 0..5 {
        driver.execute("window.scrollBy(0, 800);", vec![]).await?;
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }

    // Get all products
    let produk_elements = driver.find_all(By::Css(".produk-card")).await?;
    let mut hasil = Vec::new();

    for el in &produk_elements {
        let judul = el.find(By::Css(".produk-judul")).await?
            .text().await?;

        let harga = el.find(By::Css(".produk-harga")).await?
            .text().await?;

        // The rating may not always exist
        let rating = el.find(By::Css(".produk-rating")).await
            .ok()
            .map(|r| async move { r.text().await })
            .map(|f| tokio::runtime::Handle::current().block_on(f))
            .and_then(|r| r.ok());

        hasil.push(HasilScraping { judul, harga, rating });
    }

    println!("Found {} products", hasil.len());
    Ok(hasil)
}

CI/CD Configuration #

# Docker Compose for CI/CD with Selenium Grid
# docker-compose.yml for testing
services:
  chrome:
    image: selenium/standalone-chrome:latest
    ports:
      - "4444:4444"
    shm_size: "2gb"
    environment:
      - SE_NODE_MAX_SESSIONS=5
      - SE_NODE_OVERRIDE_MAX_SESSIONS=true

  test:
    build: .
    depends_on:
      - chrome
    environment:
      - WEBDRIVER_URL=http://chrome:4444
    command: cargo test --test e2e_tests
// Get the WebDriver URL from an environment variable for flexibility
async fn buat_driver() -> WebDriverResult<WebDriver> {
    let url = std::env::var("WEBDRIVER_URL")
        .unwrap_or_else(|_| "http://localhost:4444".to_string());

    let mut caps = DesiredCapabilities::chrome();
    caps.set_headless()?;
    caps.add_chrome_arg("--no-sandbox")?;
    caps.add_chrome_arg("--disable-dev-shm-usage")?;
    caps.add_chrome_arg("--window-size=1920,1080")?;

    WebDriver::new(&url, caps).await
}

Summary #

  • Always driver.quit() at the end — close the WebDriver session even if an error occurs. Use the pattern let result = operasi().await; driver.quit().await?; result to guarantee cleanup always happens.
  • Use query(...).wait(timeout, interval) instead of sleep — explicit waits are far more reliable than fixed sleeps. Wait for specific conditions, not guessed times.
  • Headless mode for servers and CIcaps.set_headless()? + --no-sandbox + --disable-dev-shm-usage is the standard combination for display-less environments.
  • CSS selectors are more stable than XPath — use classes, IDs, and attributes with semantic meaning. Avoid fragile order-based XPath.
  • Take screenshots when tests fail — capture a screenshot when an assertion fails to make debugging easier. Save with a name containing a timestamp or the test name.
  • By::Css is the most flexible — supports all CSS selectors including :nth-child, [attribute], class combinations, and pseudo-classes.
  • Scraping JavaScript-heavy pages — scroll to trigger lazy loading, wait for network idle, and use driver.execute() to interact with state that isn’t accessible via the regular DOM.
  • Use Selenium Grid via Docker — for parallel testing in CI, use the selenium/standalone-chrome Docker image and configure the driver URL from an environment variable.
  • thirtyfour vs fantoccini — both are WebDriver clients for Rust; thirtyfour is more ergonomic and actively developed; fantoccini is more minimal. For most cases, thirtyfour is the better choice.

← Previous: Diesel   Next: Articles & Resources →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact