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.thirtyfourhas no dedicated locator for the HTMLnameattribute. Match it with a CSS attribute selector instead:By::Css("[name='name-example-1']"). - No reliable cross-version
Selecthelper. For<select>dropdowns, don't depend on aSelect-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
seleniumcrate exists. It doesn't, meaningfully, for this purpose.thirtyfouris the answer — see the top of this file. - Forgetting to start
chromedriver(orgeckodriver) before running the code.thirtyfourconnects to an already-running WebDriver server; if none is listening on the port you gaveWebDriver::new(...), you'll get a connection-refused error, not a helpful "browser not found" message. - Forgetting
#[tokio::main]/#[tokio::test].thirtyfouris async-only. Everymainor 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 ofmain, 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 itsResult, calldriver.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::Namelocator exists. It doesn't. Use a CSS attribute selector instead:By::Css("[name='some-name']").
Learn more / real examples
demo-selenium-rust— the locator-strategy walkthrough this skill's worked example is drawn from, run against the free fixture page testingexamples.github.io.demo-selenium-rust-for-google-searchanddemo-selenium-rust-for-google-maps— the same patterns applied to Google Search and Google Maps. Illustrative only: these show tool syntax against real-world pages, but Google's Terms of Service restrict automated querying of Google Search and Google Maps, so these repos are not meant to be run repeatedly against the live sites.demo-selenium-rust-for-nhs-wales— a real test suite with real assertions (the#[tokio::test]+assert_eq!pattern from this skill, at scale) against nhs.wales.- docs.rs/thirtyfour — the crate's full API reference.
- testingexamples.github.io — the free, stable fixture page the generic demo targets; safe to run against repeatedly (unlike Google Search/Maps above).
---
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.