Selenium interviews for three years of experience (two to four years) ask less about what a wait is and more about what you did: how you made test data work on every run, how you handled a date picker or a disappearing toast, how you traced a crash that only happened in CI, and what code review taught you. It is written for automation testers with about two to four years of Selenium work, the stage where you own the tests for a feature but someone else still designs the framework. Each question shows what the interviewer is checking, the shape of a good answer and a short spoken answer, with Java examples. Swap in your own stories.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Unique per run: build the email from a random part, not a fixed string.
Safe in parallel: a millisecond timestamp can repeat across threads; a random UUID part will not in practice.
Traceable: a clear prefix and the test name, so you can find and clean up test records.
“The first version used a fixed email, so it only worked on a fresh database. My quick fix was adding the current time in milliseconds, and that worked until we turned on parallel runs, when two threads sometimes got the same millisecond and one test failed as a duplicate. Now I have a small helper that builds the email from a short prefix, the test name and part of a random UUID. The prefix, autotest, means we can spot these accounts and clean them up with one query or API call, and the test name tells me which test made a record when I look at it later. I log the generated email at the start of the test, so if it fails I can log in as that user and look around.”
public static String uniqueEmail(String testName) {
String id = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
return "autotest+" + testName.toLowerCase() + "-" + id + "@example.com";
}
// uniqueEmail("signup") gives something like autotest+signup-3f9c1a2b7d4e@example.com
Deleting the user by hand before each run, or adding a timestamp and calling it unique.
The trap: the message and the updated screen can come from the front end's own state, even when the save failed.
Check the source: reload the page and read the value again, or fetch the record through the API.
Keep it cheap: one persistence check per save flow, not after every field.
“In one project our profile tests all passed, but a real bug got through: the page showed the new phone number and a Saved message, while the API call behind it was failing quietly and nothing was stored. The screen was just showing the front end's own state. After that I changed the key tests. After saving, the test either reloads the page and reads the field again, or calls the same API the app uses to fetch the profile and checks the stored value. The API check is faster and gives a clearer failure, and the reload check proves the page reads the value back correctly. I don't do this for every field in every test, just once per save flow, so the suite doesn't slow down.”
profilePage.setPhone("0123 456 789");
profilePage.save();
wait.until(ExpectedConditions.visibilityOfElementLocated(By.cssSelector("[data-testid='saved']")));
driver.navigate().refresh();
assertEquals(profilePage.phone(), "0123 456 789"); // read back after reload
Treating the success message as proof that the data was saved.
Own your data: each test creates what it needs with a unique name and cleans up after.
Stop relying on seed data: no test assumes a record someone else can touch.
Talk: agree a naming rule or reserved accounts with the team.
“First I'd accept that in a shared environment I can't expect data to stay the same, so the fix is mostly on my side. I'd change the tests so each one creates its own user and records, with a clear prefix and a random part in the name, and removes them at the end. Anything that really has to be long-lived, like an admin account, I'd agree with the team as reserved, with a clear name so nobody edits it by mistake. I'd bring it up at stand-up without pointing at anyone, just saying our automated runs break when these accounts change, here's what I'm doing and here's what would help. If it keeps happening, I'd ask for a nightly reset or a small environment just for automation.”
Asking everyone else to stop touching the data while the tests keep depending on it.
CAPTCHA: don't automate it; ask for it to be off or in test mode in the test environment.
One-time code: read it from a test mailbox API, or use test accounts with a known code in test only.
Coverage: one test of the real code step, the rest log in through a session.
“A CAPTCHA exists to stop automation, so trying to solve it in a test is the wrong fight. I'd go to the developers before it's released and ask for it to be switched off or put in its provider's test mode in our test environments, never in production. For the email code, I'd pick one of two ways. Either the test reads the email through the API of a test mailbox service and pulls the code out, or test accounts in the test environment get a fixed code. I'd keep one or two tests that go through the full code step using the mailbox, so the real flow is covered, and every other test would skip login by injecting a session. I'd also check the bypass is guarded by environment config, so it can't leak into production.”
Planning to solve the CAPTCHA with image recognition, or asking for the check to be removed everywhere.
Compute the date: with the date API, never a hard-coded day that expires.
Move to the month: click next and wait for the header to change, until it shows the target month, with a limit.
Pick the day: by a stable attribute holding the full date, not just the day number.
“First I work out the target with LocalDate, today plus 45 days, so the test never goes stale. Then I open the calendar and read the month header. While it doesn't match the target month and year, I click next and wait for the header text to change, because reading it straight after the click can give the old month and make the loop click too far. I cap it at about twenty clicks so a broken widget can't loop forever. For the day, I learned not to find the cell by its number alone, because calendars often show greyed days from the months either side, so there can be two cells with the same number. Ours had a data-date attribute with the full date, so I pick by that, then check the input shows the date I expect.”
LocalDate target = LocalDate.now().plusDays(45);
String wanted = target.format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH));
By header = By.cssSelector(".calendar-header");
By next = By.cssSelector(".calendar-next");
driver.findElement(By.id("check-in")).click();
for (int i = 0; i < 20; i++) {
String shown = driver.findElement(header).getText();
if (shown.equals(wanted)) break;
driver.findElement(next).click();
wait.until(d -> !d.findElement(header).getText().equals(shown));
}
assertEquals(driver.findElement(header).getText(), wanted);
driver.findElement(By.cssSelector("[data-date='" + target + "']")).click();
Hard-coding a date that will expire, or clicking the first cell whose text is the day number.
Type: enough characters to narrow the list.
Wait for the exact option: a locator on its text, waited until clickable.
Confirm: check the field's value after the click.
“My first version typed a few letters, collected all the suggestion items with findElements and looped to find the right one. It failed now and then with stale element errors, because the list re-renders each time the server answers, and I was holding elements from the older list. What works better is to wait for the exact option directly: a locator that matches the item by its text, with an explicit wait until it's clickable, and then click it. That way each attempt looks the element up fresh. After the click I check the input's value, since some widgets fill the field with a longer label than the suggestion text. And I type enough characters to make the target appear near the top, so it isn't below a scroll area.”
WebElement city = driver.findElement(By.id("city"));
city.sendKeys("Lond");
By option = By.xpath("//ul[@role='listbox']/li[normalize-space()='London']");
new WebDriverWait(driver, Duration.ofSeconds(10))
.until(ExpectedConditions.elementToBeClickable(option))
.click();
assertEquals(city.getDomProperty("value"), "London");
Pressing the down arrow a fixed number of times and assuming the right item is selected.
Cause: the input's value lives in the framework's state; clear() can change the DOM without that state updating.
Fix: clear the way a user does, select all then delete, before typing.
Verify: read the value back after typing and check the form accepts it.
“In a React form the value you see is driven by the component's state. clear() changes the value in the page, but depending on the version and the component, the framework may not treat that as user input, so its state still holds the old text. On the next render the old value comes back, or the validation still thinks the field is empty. I hit this on an edit form in my current project. The fix was to clear it the way a person would: select all with the keyboard and press delete, then type the new value. Because the shortcut is Command on Mac and Control elsewhere, I put that in one helper. After typing I read the value back, and I check the save button enables, which proves the form saw the change.”
Keys selectAll = System.getProperty("os.name").toLowerCase().contains("mac")
? Keys.COMMAND : Keys.CONTROL;
field.sendKeys(Keys.chord(selectAll, "a"), Keys.DELETE);
field.sendKeys("New company name");
assertEquals(field.getDomProperty("value"), "New company name");
Setting the value with JavaScript and moving on, so the test no longer types like a user.
Wait and read in one step: wait for the text to be present rather than finding the element later.
Polling: the default wait polls every half second; poll faster for short-lived elements, and ignore not-found while polling.
Optional: confirm it disappears if that is part of the requirement.
“The flaky version found the toast, did some other step, then read its text, and by then the toast had gone. I changed it to wait for the text itself, with textToBePresentInElementLocated, so the condition is checked while the toast is on screen. WebDriverWait polls every 500 milliseconds by default, which is fine for most things but is a big slice of a two-second toast, so for these checks I use a FluentWait polling every 100 milliseconds. One catch there: a plain FluentWait doesn't ignore NoSuchElementException the way WebDriverWait does, so I add that, or the wait fails the moment it looks before the toast exists. I also asked the developers for a stable data-testid on the toast, because its classes changed with the message type.”
By toast = By.cssSelector("[data-testid='toast']");
new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(5))
.pollingEvery(Duration.ofMillis(100))
.ignoring(NoSuchElementException.class)
.until(ExpectedConditions.textToBePresentInElementLocated(toast, "Saved"));
Adding Thread.sleep before reading the text, which makes it miss the toast even more often.
Loop: while the target is not present, scroll to the bottom.
Wait for growth: after each scroll, wait until the item count goes up.
Stop: if the count stops growing, fail with a clear message.
“I loop while the product I want isn't on the page. In each pass I count the loaded cards, scroll to the bottom with JavaScript, and then wait a few seconds for the count to go up. If the wait times out, the list has stopped loading, so I fail with a message saying how many cards loaded and that the product wasn't among them. That's much better than scrolling a fixed ten times, which either wastes time or stops too early. Each pass looks the cards up fresh, so there are no stale references. I'd also say to the team that if this list is very long, searching or filtering for the product is a better test path, and the infinite scroll itself gets one small test of its own.”
By cards = By.cssSelector(".product-card");
By target = By.xpath("//div[contains(@class,'product-card')][.//h3[normalize-space()='Blue Kettle']]");
JavascriptExecutor js = (JavascriptExecutor) driver;
while (driver.findElements(target).isEmpty()) {
int before = driver.findElements(cards).size();
js.executeScript("window.scrollTo(0, document.body.scrollHeight);");
try {
new WebDriverWait(driver, Duration.ofSeconds(5))
.until(d -> d.findElements(cards).size() > before);
} catch (TimeoutException end) {
throw new AssertionError("Blue Kettle not found; list stopped at " + before + " cards");
}
}
Scrolling a fixed number of times with sleeps in between and no clear failure when the item is missing.
Set the folder: browser preferences give a known, empty download directory per run.
Wait right: poll until the file exists and the partial download file has gone.
Check content: read the file and assert on rows, not just that a file exists.
“Selenium drives the page, but the downloaded file is just a file on disk, so the test has to go and look for it. I set Chrome's preferences so downloads go to a fresh folder for each run and it doesn't ask where to save. After clicking Export, I poll that folder with a wait until a .csv file is there and the temporary .crdownload file has gone, because checking too early gives you a half-written file. Then I open it and check the header and the row count against the data the test created. On Grid it's different, because the file lands on the node machine, not where the test runs, so there we either use the Grid's download support or check the export through the API instead.”
Map<String, Object> prefs = new HashMap<>();
prefs.put("download.default_directory", downloadDir.toString());
prefs.put("download.prompt_for_download", false);
ChromeOptions options = new ChromeOptions();
options.setExperimentalOption("prefs", prefs);
WebDriver driver = new ChromeDriver(options);
A Thread.sleep after the click and then assuming the file is complete.
Trigger and wait: click the header and wait for a sign the sort is done.
Read values: collect the column, turn the text into numbers.
Compare: against a sorted copy of the same list.
“I click the Price header and then wait for something that says the sort finished, in our app the header's aria-sort attribute changes to ascending. Then I read every price cell, strip the currency symbol and separators, and parse them as numbers. Comparing the text would be wrong, because as strings 100 comes before 20. I make a copy, sort the copy, and assert both lists are equal. If they aren't, the assertion message shows both lists, which makes the failure easy to read. One limit I call out: this only checks the rows on the first page. If the table is paginated and sorted on the server, I also check the first row on page two isn't cheaper than the last row on page one, or I check the API response.”
By priceHeader = By.cssSelector("th[data-col='price']");
driver.findElement(priceHeader).click();
wait.until(ExpectedConditions.attributeToBe(priceHeader, "aria-sort", "ascending"));
List<Double> prices = driver.findElements(By.cssSelector("td.price")).stream()
.map(td -> Double.parseDouble(td.getText().replaceAll("[^0-9.]", "")))
.collect(Collectors.toList());
List<Double> sorted = new ArrayList<>(prices);
Collections.sort(sorted);
assertEquals(prices, sorted);
Comparing the cell text as strings, or reading the cells before the sort has finished.
The limit: a canvas is one element; the bars are pixels, not page elements.
Check the data: assert on the API response the chart is drawn from.
Check what is HTML: title, legend, totals and tooltips; a screenshot comparison only with a tolerance.
“I told the team up front that Selenium sees the canvas as a single element, so there's no locator for a bar. I split the check into three. The numbers themselves I test against the API that feeds the chart, since that's where a wrong value would come from. On the page I check what is real HTML: the chart title, the legend items, the date range and the total shown above the chart. And I check the canvas is displayed and has a real size, so an empty chart doesn't slip through. We tried a screenshot comparison of the chart area, but small rendering differences between machines made it noisy, so we kept that to one nightly check with a tolerance. For one chart we also asked the developers for a hidden data table for screen readers, which gave us something real to assert on.”
Saying charts can't be tested at all, or comparing full screenshots with zero tolerance.
Start point: click into the first field.
Move and read: press Tab, then read the focused element with switchTo().activeElement().
Compare: the list of focused ids against the expected order, then submit with Enter.
“I click into the first field, then press Tab on the focused element and read which element now has focus with switchTo().activeElement(), recording its id each time. At the end I compare that list with the order the designer expects, so a failure shows exactly where focus jumped. I compare ids, or names where there's no id, rather than labels, since labels change with copy edits. I also finish by pressing Enter on the submit button to prove the whole form works without a mouse. This caught a real bug for us: a custom country dropdown that took focus but couldn't be opened from the keyboard. It doesn't replace a proper accessibility audit, but it's a cheap check that stops that bug coming back.”
List<String> expected = List.of("name", "email", "password", "country", "terms", "submit");
driver.findElement(By.id("name")).click();
List<String> actual = new ArrayList<>();
actual.add(driver.switchTo().activeElement().getDomAttribute("id"));
for (int i = 1; i < expected.size(); i++) {
driver.switchTo().activeElement().sendKeys(Keys.TAB);
actual.add(driver.switchTo().activeElement().getDomAttribute("id"));
}
assertEquals(actual, expected);
Checking only that each field can be clicked, which says nothing about keyboard users.
Read the pattern: only in the container, only on heavy pages, never the same test twice.
Cause: a Docker container gets a small /dev/shm by default, and Chrome uses shared memory heavily.
Fix: give the container more shared memory, or pass --disable-dev-shm-usage so Chrome uses /tmp instead.
“The pattern was the clue: it only happened in the CI container, on the pages with big tables and charts, and never in the same test twice. That pointed at the environment, not the code. Chrome uses shared memory a lot, and a Docker container only gets a small /dev/shm by default, so on heavy pages Chrome ran out and the tab crashed. We fixed it two ways. The container got a bigger shared memory size with the shm-size setting in the CI config, and as a safety net we added the --disable-dev-shm-usage argument to ChromeOptions for CI runs, which makes Chrome write those files to /tmp instead. The crashes stopped. I also added the container's memory use to the job log, so the next resource problem shows up sooner.”
ChromeOptions options = new ChromeOptions();
if (System.getenv("CI") != null) {
options.addArguments("--headless=new", "--disable-dev-shm-usage");
}
WebDriver driver = new ChromeDriver(options);
Adding retries until the crashes stop showing, without looking at the environment.
Symptom: the click succeeded from Selenium's side, so there is no exception; the page ignored it.
Cause: on a fast agent the button is on screen before the script that handles it has loaded, for example on a server-rendered page.
Fix: wait for a signal that the app is ready, not for the button alone, and never a fixed sleep.
“No exception meant Selenium did click, so the question was why the page ignored it. The failure screenshot showed the button there and the cart empty. I added timestamps to the step log and saw the click happened a fraction of a second after the page loaded. Our shop page is rendered on the server, so the HTML, button included, shows up before the JavaScript that attaches the click handler has finished loading. Waiting for clickable only means visible and enabled, not wired up. On my laptop the timing just happened to work. The fix was to ask the developers for a small ready marker, an attribute the app sets on the body once it has started, and our page objects wait for it after every page load. That also cleared up three other flaky tests.”
driver.get(baseUrl + "/product/42");
wait.until(ExpectedConditions.attributeToBe(By.tagName("body"), "data-app-ready", "true"));
driver.findElement(By.cssSelector("[data-testid='add-to-cart']")).click();
Adding a Thread.sleep before the click, or switching to a JavaScript click that hides the problem.
Find the rule: how the app decides to show it: a random share of visits, a cookie, a feature flag.
Control it: set the cookie or flag that marks it as seen, or switch it off for automation users.
Cover it once: one test that makes the pop-up show and checks it can be closed.
“The random pattern was the hint, because the failing test changed every time. The screenshots showed a survey box over the page, and the developers told me it appeared on a random share of visits unless a cookie said the user had already answered. We used two fixes. In the test environment the survey is now behind a feature flag that's off for our automation users, and for other environments our base test sets the survey-seen cookie right after opening the site. What I didn't want was a try and catch around every click that closes the pop-up if it's there, because that slows every step and can hide real overlap bugs. And there's one test with the flag on that checks the survey opens and can be closed.”
A helper that looks for the pop-up and closes it before every click.
Input: a system property or TestNG parameter, with a default.
One factory: a single method turns the name into a configured driver.
Tests stay clean: they never create a driver themselves.
“I added one small driver factory. It reads the browser name from a system property, so CI can pass something like -Dbrowser=firefox, and falls back to Chrome when nothing is passed. A switch statement builds the right driver with its options: window size, headless when the job asks for it, download folder and so on. The base test calls the factory in setup, and no test ever says new ChromeDriver. When we later moved to Grid, only the factory changed, to build a RemoteWebDriver with the same options. The first Firefox run found a few failures that weren't browser bugs at all, just tests that relied on Chrome's timing, so it was worth having.”
public static WebDriver create() {
String browser = System.getProperty("browser", "chrome").toLowerCase();
switch (browser) {
case "firefox":
return new FirefoxDriver(new FirefoxOptions());
case "edge":
return new EdgeDriver(new EdgeOptions());
default:
return new ChromeDriver(new ChromeOptions());
}
}
Copying the whole suite per browser, or creating drivers inside individual tests.
Component object: one class for the grid, built from the grid's root element.
Search inside the root: relative locators, so two grids on one page do not clash.
Pages compose it: each page object hands back its grid instead of copying code.
“I pulled the grid into its own class, a component object. It takes the grid's root element, and every lookup inside it is relative to that root, so the row and pager locators are written once. It has methods like rowCount and clickAction for the row that holds a given value. Each page object just returns a DataGrid built from its own container, so the orders page and the users page share the same code. The gotcha that bit me was XPath: inside the root it has to start with a dot, otherwise a double slash searches the whole page and finds the wrong grid's row. When the frontend team changed the grid's markup, I fixed one class instead of eight. And the page object builds a fresh grid each time it's asked, so a reloaded grid never leaves me holding a stale root.”
public class DataGrid {
private final WebElement root;
public DataGrid(WebElement root) {
this.root = root;
}
public int rowCount() {
return root.findElements(By.cssSelector("tbody tr")).size();
}
public void clickAction(String rowText, String action) {
root.findElement(By.xpath(".//tbody/tr[td[normalize-space()='" + rowText + "']]"
+ "//button[normalize-space()='" + action + "']")).click();
}
}
// ordersPage.grid().clickAction("ORD-1042", "Refund");
Copying the grid locators into every page object, or writing XPaths inside the root that start with a double slash.
See it fail: break the expected value or point it at a known bad state and check it goes red with a clear message.
See it hold: run it several times, and in parallel with its neighbours.
Run it like CI: headless, on the same browser and settings the pipeline uses.
“The first thing I do is make it fail on purpose. I change the expected text or the amount it checks, and I make sure it goes red and the message says what went wrong. Once, a test of mine stayed green even with a wrong expected value, and it turned out the assertion was inside a branch that never ran. After that I run it maybe ten times in a row, and together with the other tests in its class in parallel, because that's where timing and shared data problems show. Finally I run it headless with the same browser settings CI uses before I open the pull request. It takes an extra half hour, but it saves the team from a test that's either useless or flaky.”
Saying it passed once locally, so it's ready.
The code: what you wrote and why it seemed fine.
The feedback: what the reviewer pointed out.
The habit now: what you do differently every time.
“Early in my second year I raised a pull request with a new page object for the checkout page. It had a Thread.sleep of three seconds after the pay button, and a couple of methods that did assertions inside the page object. My senior asked two things: what exactly was I waiting for, and how would another test reuse this page if it wanted to check a failed payment. That made me see the sleep was hiding a real condition, the confirmation panel appearing, so I replaced it with an explicit wait on that. And I moved the assertions into the tests, so the page object only returns what's on the page. Now before I raise anything I search my change for sleep, and I ask whether a different test could use my page methods as they are.”
Saying you have never had useful review feedback, or describing it defensively.
The task and the guess: what you estimated and what it really took.
What you missed: test data, environment, tricky widgets, making it stable in CI.
Now: a short spike first and a split between writing and stabilising.
“I estimated two days to automate the new refund flow, because it was only four screens. It took more than a week. Writing the steps was the quick part. What I missed was that refunds needed an order that had already been paid and shipped, and there was no easy way to create one, so I had to work with a developer on a test-data endpoint. The payment screen was an iframe from a third party that behaved differently in the test environment, and once it ran in CI it was flaky for two days until I found a timing issue. I told my lead as soon as I saw it slipping. Now I spend an hour on a spike first, list what data and setup the flow needs, and estimate getting it stable in CI as its own piece.”
Blaming the environment or developers entirely, with nothing you would do differently.
Before code: read the acceptance criteria and pick which checks belong in the UI.
While building: agree test ids and data setup with the developer.
After merge: add to the right suite, watch the first CI runs, fix what shows up.
“The one I'd pick is saved addresses in our checkout. When the story was ready I went through the acceptance criteria and split them: validation rules I left to the developer's API tests, and in Selenium I covered add, edit, delete and choosing a saved address at checkout. I asked the developer early to add data-testid attributes on the address cards, since the classes were generated, and to expose an API call to create addresses for setup. I wrote the page object and four tests while the feature was being built, so they were ready when it hit the test environment. I tagged two of them as smoke. After merge I watched the pipeline for a week, and one test failed once when two addresses had the same name, which led to a small real bug.”
A story that starts and ends with writing the script, with no thought for what to cover or how it runs in CI.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.