Skills on AI 459 skills

Active theme: Light

Selenium Testing With Rust

Use when asked to write, explain, debug, or extend Selenium-style WebDriver browser automation code in Rust using the thirtyfour crate — locating elements, performing actions, explicit waits, and writing real assertions.

There is no official "Selenium" crate — use thirtyfour

If you go looking for a crate literally named selenium, you won't find one that meaningfully does this job. Rust has no official Selenium binding. What this skill teaches is the thirtyfour crate. thirtyfour speaks the same underlying W3C WebDriver protocol that Selenium itself uses — it talks to the same chromedriver / geckodriver server, drives a real browser the same way, and does the same job as Selenium's language bindings. It just isn't Selenium-branded.

So: when someone asks for "Selenium in Rust," "Selenium-style tests in Rust," or "WebDriver in Rust," the answer is thirtyfour. Don't propose or invent a selenium crate — it doesn't exist for this purpose.

Install

cargo add thirtyfour anyhow tokio --features tokio/full

or add manually to Cargo.toml:

[dependencies]
thirtyfour = "0.3"
tokio = { version = "1", features = ["full"] }
anyhow = "1"

thirtyfour is async-only — every call goes through tokio.

Prerequisite: start a WebDriver server first

Unlike some other language bindings (e.g. some Selenium Manager setups) that can launch the browser and driver together, thirtyfour only connects to a WebDriver server that is already running and listening on a port. It does not start one for you. Start it separately, commonly on port 9515:

chromedriver --port=9515 &

(or the equivalent geckodriver invocation for Firefox). If you skip this step, WebDriver::new(...) will fail with a connection-refused error — see "Common pitfalls" below.

Four core concepts: locate, act, wait, assert

Every browser-automation tool in this project's materials boils down to the same four moves. Here's what each looks like in thirtyfour.

1. Locate — By + .query(...)

thirtyfour finds elements with a By locator strategy passed to driver.query(...):

driver.query(By::Id("some-id")).single().await?;
driver.query(By::ClassName("some-class")).single().await?;
driver.query(By::Css("[name='some-name']")).single().await?;
driver.query(By::LinkText("Some Link")).single().await?;
driver.query(By::XPath("//input[@type=\"submit\"]")).single().await?;

Note two gaps worth knowing up front:

  • No By::Name. thirtyfour has no dedicated locator for the HTML name attribute. Match it with a CSS attribute selector instead: By::Css("[name='name-example-1']").
  • No reliable cross-version Select helper. For <select> dropdowns, don't depend on a Select-style convenience API across versions. Click the option element directly, e.g. By::Css("#my-select option:first-child").

2. Act — element methods

Once you have an element, act on it:

element.text().await?;                 // read visible text
element.attr("value").await?;          // read an attribute (Option<String>)
element.send_keys("hello").await?;     // type into an input
element.click().await?;                // click (checkbox, radio, button, option, …)

3. Wait — thirtyfour does NOT auto-wait

Unlike Playwright, thirtyfour does not automatically retry a query until an element appears. A plain .query(...).single() fails immediately if the element isn't there yet. For an explicit wait, use .wait(...) on the query builder, giving it a timeout and a poll interval:

use std::time::Duration;

let element = driver
    .query(By::Id("id-example-1"))
    .wait(Duration::from_secs(10), Duration::from_millis(250))
    .single()
    .await?;

This is thirtyfour's equivalent of Playwright's implicit auto-waiting: poll every 250ms for up to 10 seconds before giving up.

4. Assert — see "From walkthrough to real test" below.

Full worked example

This is real, working code (lightly annotated) from the sibling repo demo-selenium-rust, run against the free fixture page testingexamples.github.io:

use thirtyfour::prelude::*;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Connect to a WebDriver server (e.g. chromedriver listening on
    // 9515, started separately) and open a session.
    let caps = DesiredCapabilities::chrome();
    let driver = WebDriver::new("http://localhost:9515", caps).await?;

    let result = run_demo(&driver).await;
    if let Err(ref err) = result {
        eprintln!("{err:?}");
    }
    driver.quit().await?;
    result
}

async fn run_demo(driver: &WebDriver) -> anyhow::Result<()> {
    driver.goto("https://testingexamples.github.io").await?;

    // Find an element by id.
    let element_by_id = driver.query(By::Id("id-example-1")).single().await?;
    println!("By id: {}", element_by_id.text().await?);

    // Find an element by name attribute — thirtyfour has no dedicated
    // By::Name locator, so match it with a CSS attribute selector.
    let element_by_name = driver.query(By::Css("[name='name-example-1']")).single().await?;
    println!("By name: {}", element_by_name.text().await?);

    // Find an element by class name.
    let element_by_class_name = driver.query(By::ClassName("class-example-1")).single().await?;
    println!("By class name: {}", element_by_class_name.text().await?);

    // Find a link element by its visible text.
    let element_by_link_text = driver.query(By::LinkText("Link Example 1")).single().await?;
    println!("By link text: {}", element_by_link_text.text().await?);

    // Find an element by an XPath expression. The target is a submit
    // input, whose label lives in its value attribute, not text content.
    let element_by_xpath = driver.query(By::XPath("//input[@type=\"submit\"]")).single().await?;
    let submit_value = element_by_xpath.attr("value").await?.unwrap_or_default();
    println!("By XPath: {submit_value}");

    // Fill a text input.
    let text = driver.query(By::Id("text-example-1-id")).single().await?;
    text.send_keys("hello").await?;

    // Click a checkbox input.
    let checkbox = driver.query(By::Id("checkbox-example-1-id")).single().await?;
    checkbox.click().await?;

    // Click a radio input.
    let radio = driver.query(By::Id("radio-example-1-option-1-id")).single().await?;
    radio.click().await?;

    // Choose a select option, by index — thirtyfour has no reliable
    // cross-version Select helper, so click the first <option> directly
    // via a CSS :first-child selector as a version-safe fallback.
    let first_option = driver
        .query(By::Css("#select-example-1-id option:first-child"))
        .single()
        .await?;
    first_option.click().await?;
    println!("Selected option value: {}", first_option.attr("value").await?.unwrap_or_default());

    Ok(())
}

Notice the main function's shape: it runs the real work in run_demo, captures the Result, calls driver.quit() unconditionally, and only then returns the captured result. That pattern matters — see "Common pitfalls."

From walkthrough to real test

The example above is a walkthrough: it println!s what it finds so a human can eyeball the output. That's useful for exploring a page, but it isn't a test — nothing fails if the page changes.

The idiomatic way to turn this into a real test in Rust is #[tokio::test] (since thirtyfour is async) combined with Rust's built-in assert_eq! / assert! macros — no separate test-runner crate needed:

#[tokio::test]
async fn id_example_has_expected_text() -> anyhow::Result<()> {
    let caps = thirtyfour::DesiredCapabilities::chrome();
    let driver = thirtyfour::WebDriver::new("http://localhost:9515", caps).await?;
    driver.goto("https://testingexamples.github.io").await?;

    let text = driver.query(thirtyfour::By::Id("id-example-1")).single().await?.text().await?;
    assert_eq!(text, "Id Example 1");

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

Run it the normal Rust way: cargo test. Each #[tokio::test] function gets its own async runtime, so each test can open (and must close) its own WebDriver session. For a real test suite grounded in real assertions, see demo-selenium-rust-for-nhs-wales in "Learn more" below.

Common pitfalls

  • Assuming a selenium crate exists. It doesn't, meaningfully, for this purpose. thirtyfour is the answer — see the top of this file.
  • Forgetting to start chromedriver (or geckodriver) before running the code. thirtyfour connects to an already-running WebDriver server; if none is listening on the port you gave WebDriver::new(...), you'll get a connection-refused error, not a helpful "browser not found" message.
  • Forgetting #[tokio::main] / #[tokio::test]. thirtyfour is async-only. Every main or test function that calls it needs a Tokio runtime attribute.
  • Not calling driver.quit() on every path. If a query fails partway through and you ?-propagate the error straight out of main, the browser session (and the OS-level browser process) can leak. Use the pattern from the worked example: run the real work in a helper function, capture its Result, call driver.quit().await? unconditionally, then return the captured result:
  let result = run_demo(&driver).await;
  if let Err(ref err) = result {
      eprintln!("{err:?}");
  }
  driver.quit().await?;
  result
  • Assuming a By::Name locator exists. It doesn't. Use a CSS attribute selector instead: By::Css("[name='some-name']").

Learn more / real examples

---

AGENTS.md and spec/index.md in this repo are the source of truth for this skill's own scope — if this file ever disagrees with those, they win.

View selenium-testing-with-rust/SKILL.md on GitHub