PRACTICAL GUIDE / custom ExpectedCondition diagnostic messages

Make Selenium timeouts explain the state they actually saw

Build Java ExpectedConditions that preserve the last observed state, expose the real timeout cause, and produce useful diagnostics across failing CI runs.

By The Testing AcademyUpdated August 7, 202620 min read
All field guides
In this guide7 sections
  1. Why ordinary timeout text often hides the answer
  2. Build one condition around one domain decision
  3. Three waits that need different observations
  4. Tell a slow transition from a broken one
  5. Make the final observation prove it belongs to the wait
  6. Roll diagnostic waits into an established framework
  7. What diagnostic conditions cost, and when not to add one

What you will learn

  • Why ordinary timeout text often hides the answer
  • Build one condition around one domain decision
  • Three waits that need different observations
  • Tell a slow transition from a broken one

A checkout test waits eight seconds and reports only that a condition failed. The page actually showed PAYMENT_DECLINED on every poll, but the exception discarded that fact. Increasing the timeout turns a clear product rejection into a slower, equally vague failure.

A useful wait names the state it expects and preserves the last state it observed. Selenium already provides the hooks for that in Java. The hard part is deciding which evidence belongs in the condition and which exceptions must escape immediately.

Why ordinary timeout text often hides the answer

ExpectedCondition<T> is a function from WebDriver to a value. FluentWait.until() keeps applying that function until it returns a value that is neither null nor false, throws an unignored exception, reaches the timeout, or the thread is interrupted. A Boolean condition succeeds only on true; a condition that returns a WebElement or domain object succeeds when that value is non-null.

Java WebDriverWait specializes FluentWait<WebDriver>. It ignores NotFoundException by default, which is useful for an element that has not appeared yet. Other exceptions propagate unless code explicitly adds them to the ignored set.

On timeout, current Selenium Java builds a message with the configured duration and polling interval. When no custom message is configured, it describes the wait as “waiting for” the condition object's toString() value. A lambda usually contributes an implementation-shaped string that means little in a report. A named class can override toString() with domain language.

withMessage(String) replaces that default description with a fixed message. withMessage(Supplier<String>) evaluates the supplier when time expires, which is better when the condition tracks the latest text, count, or state. Do not calculate the message before polling and expect it to update.

There is a subtle exception rule worth knowing. When an ignored exception occurs, FluentWait remembers it as the latest cause. If a later poll returns null or false, the current implementation clears that remembered exception. The final TimeoutException may therefore have no cause even though an earlier poll saw a missing element. If that transition matters, store a concise observation in the condition rather than relying solely on the exception cause.

The cause and the message answer different questions. A NoSuchElementException cause says a lookup failed on the last relevant poll. A diagnostic string can say the order status element was present, held DECLINED, and exposed a payment error code. Preserve both when available.

A fixed failure example from a real test run will usually follow this shape:

Shell
org.openqa.selenium.TimeoutException:
Expected condition failed: order ORD-1048 status to become READY;
last observed=PAYMENT_DECLINED; error=card_declined; polls=...
(tried for 8 seconds with 250 milliseconds interval)
Driver info: org.openqa.selenium.chrome.ChromeDriver
Session ID: ...

The order and state are sample fixture values, and the ellipses stand for actual run-specific data. Poll count, driver information, capabilities, and session ID should come from the execution. Do not invent them in a test report.

Build one condition around one domain decision

Start with the question the test needs answered. “Is an element visible?” is enough for a generic dialog. “Did order ORD-1048 reach READY without entering a terminal error state?” needs a domain condition.

The class below locates the status on every poll, records a small observation, succeeds only on the expected state, and fails immediately when the application exposes a terminal state. It overrides toString() so the default timeout remains useful even without withMessage().

Java
import java.util.Set;
import org.openqa.selenium.By;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedCondition;

final class OrderState implements ExpectedCondition<WebElement> {
  private static final Set<String> TERMINAL_FAILURES =
      Set.of("PAYMENT_DECLINED", "CANCELLED");

  private final String orderId;
  private final String expected;
  private final By status;
  private int polls;
  private String lastObserved = "<not polled>";

  OrderState(String orderId, String expected) {
    this.orderId = orderId;
    this.expected = expected;
    this.status = By.cssSelector(
        "[data-order-id='" + orderId + "'] [data-testid='order-status']");
  }

  @Override
  public WebElement apply(WebDriver driver) {
    polls++;
    try {
      WebElement element = driver.findElement(status);
      String state = element.getText().trim();
      String error = element.getDomAttribute("data-error-code");
      lastObserved = error == null || error.isBlank()
          ? state
          : state + " (error=" + error + ")";

      if (TERMINAL_FAILURES.contains(state)) {
        throw new IllegalStateException(
            "Order " + orderId + " entered terminal state " + lastObserved);
      }
      return expected.equals(state) ? element : null;
    } catch (NoSuchElementException missing) {
      lastObserved = "<status element missing>";
      throw missing;
    }
  }

  String diagnosticMessage() {
    return "order " + orderId + " status to become " + expected
        + "; last observed=" + lastObserved
        + "; polls=" + polls;
  }

  @Override
  public String toString() {
    return diagnosticMessage();
  }
}

The condition does not catch IllegalStateException. WebDriverWait does not ignore it, so a terminal product state fails immediately instead of waiting for a timeout. It rethrows NoSuchElementException after recording context; WebDriverWait's default policy can then poll again and may retain the exception as a cause.

Use a fresh instance for one wait:

Java
import java.time.Duration;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.WebDriverWait;

OrderState ready = new OrderState("ORD-1048", "READY");

WebElement status = new WebDriverWait(
    driver,
    Duration.ofSeconds(8),
    Duration.ofMillis(250))
    .withMessage(ready::diagnosticMessage)
    .until(ready);

org.junit.jupiter.api.Assertions.assertEquals("READY", status.getText().trim());

The message supplier reads state only if the wait times out. The final assertion remains because a wait helper should not be the only expression of the test's product expectation.

Mutable fields make the condition diagnostic, but they also make it unsuitable for sharing. Do not put one instance in a static field or reuse it across parallel methods. Poll counts and last values would cross test identities, and FluentWait itself makes no thread-safety guarantee.

Keep the recorded state small. Full page source, response bodies, and customer data do not belong in a timeout message. Attach larger artifacts through the test framework with access controls and retention appropriate to the data.

Three waits that need different observations

An order state is categorical. The condition should report the last value, an error code exposed by the product, and the order identity. A terminal value such as CANCELLED is not “not ready yet”; it is a completed negative outcome and should stop polling.

A search result wait is numerical and set-based. “At least one row” can pass on a stale result from the previous query. The condition needs the query identity, current row count, and stable keys for the rows it saw. It should return the rows only when they correspond to the requested query and meet the expected count rule.

Java
import java.util.List;
import java.util.stream.Collectors;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.ui.ExpectedCondition;

final class SearchResults implements ExpectedCondition<List<WebElement>> {
  private final String query;
  private final int expectedCount;
  private final By rows = By.cssSelector("[data-testid='search-result']");
  private final By completedQuery =
      By.cssSelector("[data-testid='results'][data-query]");
  private String last = "<not polled>";

  SearchResults(String query, int expectedCount) {
    this.query = query;
    this.expectedCount = expectedCount;
  }

  @Override
  public List<WebElement> apply(WebDriver driver) {
    String renderedQuery =
        driver.findElement(completedQuery).getDomAttribute("data-query");
    List<WebElement> found = driver.findElements(rows);
    List<String> ids = found.stream()
        .map(row -> row.getDomAttribute("data-result-id"))
        .collect(Collectors.toList());

    last = "renderedQuery=" + renderedQuery
        + ", count=" + found.size()
        + ", ids=" + ids;

    boolean complete = query.equals(renderedQuery)
        && found.size() == expectedCount;
    return complete ? found : null;
  }

  @Override
  public String toString() {
    return "results for query '" + query + "' to contain "
        + expectedCount + " rows; last observed " + last;
  }
}

The data-query attribute in this example is an application contract. If the product has no way to identify which request populated the list, a test can observe a loading indicator, URL parameter, or response through another controlled fixture. Do not add a fictional attribute only in the test and then claim the UI is synchronized.

Exact count has a cost. It is correct when the fixture owns a fixed result set. It is brittle for production-like search data that changes independently. In that case, assert the presence of known seeded record IDs and the absence of a stale query marker instead of a global count.

A progress wait is temporal. A percentage can remain at 40 while work continues legitimately, or it can be stuck because the worker died. The UI condition can report the last progress value and last visible phase, but it cannot infer backend health without an exposed state. Set a business timeout based on the product contract, not an invented polling count, and preserve server or job evidence separately.

For an export job, bind every observation to the job ID rendered by the page. A progress bar left over from the previous export can reach 100 and satisfy a percentage-only condition. Read the current job ID, phase, percentage, and visible error marker in one poll. Succeed only when the expected job owns the completed state. If the job ID changes, report that fact rather than silently following the newest export.

Progress can also move backward for legitimate reasons when the product reports phases with separate percentages. A universal monotonicity check would label that contract as broken. Capture phase and percentage together, then apply rules the product actually promises. For example, 80 percent in Uploading followed by 10 percent in Indexing may be valid, while Indexing followed by Failed is terminal.

Keep condition evaluation observational. A poll should not click Retry, refresh the page, dismiss a dialog, or submit another request. FluentWait may call it many times. Side effects can create duplicate jobs and make the final state impossible to attribute. If recovery is part of the scenario, perform it once outside the condition and start a new wait with a new diagnostic identity.

Message creation should also be side-effect free. A withMessage supplier is called when timeout processing begins. If it makes fresh WebDriver calls and the browser has already become unhealthy, that new exception can obscure the timeout you were trying to explain. Have the supplier format fields the condition already recorded. Apply the same rule to toString().

A dialog readiness wait is compositional. Presence, visibility, enabled controls, and a completed data load may all matter. Avoid a condition that returns true on the first visible shell. Record which subcondition is incomplete. If the product renders an error panel, treat it as terminal instead of waiting for the success controls.

Suppose an Edit profile dialog renders its heading immediately, then fetches account data. Waiting for visibility alone lets the test type into an empty shell, and the response can overwrite the input. A domain condition can report heading=true, busy=true, nameLoaded=false, and error=none, then return the dialog only when aria-busy is false and the expected account ID owns the form. The later test still asserts the loaded name before editing. This separates readiness from content correctness.

The competing failure is a disabled Save button caused by validation. A readiness condition that waits for Save to become enabled will time out even though the UI is correctly rejecting empty required fields. The diagnostic should include which required value is missing or which visible validation message appeared. Better still, make form population and save readiness separate test steps so a failed fill does not masquerade as a slow dialog.

These examples should not be merged into a universal diagnostic condition with a map of arbitrary fields. Typed classes make the expected state and success value reviewable. A generic callback that catches everything and stores Object.toString() recreates the vague wait under a new name.

Tell a slow transition from a broken one

The final observation often provides the split. A state that advances through QUEUED, PROCESSING, and READY just after the deadline suggests either a realistic performance boundary or an undersized test timeout. A state that remains PAYMENT_DECLINED is a product or data outcome. A missing element on every poll points to navigation, selector, permission, or rendering.

Preserve chronology when it matters, but bound it. A small ring buffer of the last few state changes is more useful than hundreds of identical poll lines. Record only changes, with elapsed time taken from the actual run. Never publish illustrative timestamps as measured evidence.

The first exception has high value. If the condition sees NoSuchElementException, then later returns false because a shell appears, FluentWait may clear the earlier cause. The condition's state can retain “status missing, then shell empty” without swallowing the exception type. That sequence distinguishes late rendering from a valid status that never changed.

Do not catch every WebDriverException and return null. An invalid session, unreachable browser, bad JavaScript command, or wrong frame is not a false domain predicate. Broad catches delay the failure and replace its stack with a timeout.

Ignore StaleElementReferenceException only when redraw during evaluation is an accepted part of the condition. Even then, relocate within the next poll and record that a redraw occurred. If a supposedly stable status redraws continually, the churn may be the defect.

Timeout inflation is a diagnostic experiment, not a final repair. Run once with a longer limit and retain the state transitions. If the condition eventually succeeds at a repeatable product boundary, discuss the real service objective and CI environment. If it remains in a terminal or impossible state, more time adds latency without information.

Retries operate at a larger boundary. A test retry creates a new attempt and may use new data, a new browser session, or a different server state. Keep each attempt's diagnostic message separate. Reporting only the passing retry makes the suite look healthy while the first attempt already proved nondeterminism.

Compare the timeout message with network and application evidence under the same test identity. A UI state of PROCESSING plus a successful job response means something different from PROCESSING plus a server 500. Selenium's condition should not fabricate backend conclusions; it should make correlation possible.

A near-miss deserves special attention: the expected text is present in a hidden template while the user-visible component shows an error. A locator that searches the whole document may return success. Scope the condition to the active order region or dialog, verify visibility where required, and keep the product assertion at the same boundary.

Another near-miss comes from mixed implicit and explicit waits. A long implicit timeout applies inside each findElement() call, so one poll can consume much of the explicit wait. The displayed “250 milliseconds interval” describes requested sleep between evaluations, not the full time between their starts. Keep implicit waits minimal when precise explicit-wait diagnostics matter.

Make the final observation prove it belongs to the wait

Two timeouts can both end with last observed=PROCESSING while requiring opposite fixes. In the first, order ORD-1048 is genuinely stuck in processing. In the second, an unscoped locator is reading ORD-1049, which is progressing normally, while the requested row never entered the condition's search region. Raising the timeout delays both failures. Only the first is a slow or broken transition; the second is an observation defect.

Bind every state to the identity that produced it. For an order wait, record the expected order ID, the ID on the located container, the number of matching candidates, the number visible, the state text, and any terminal marker the product exposes. These are application-defined diagnostic fields, not Selenium configuration. A healthy final observation reads as expected ID ORD-1048, observed ID ORD-1048, one visible candidate, and state READY. A broken product transition keeps the two IDs equal but remains in PROCESSING until the deadline. A wrong-target condition shows expected ORD-1048 beside observed ORD-1049, even if the count is one and the element is visible.

The misleading fields are often the familiar ones. candidateCount=1 says the locator returned one node, not that it returned the intended node. visible=true says the node can be seen, not that it belongs to the requested order. A URL can also remain correct while a reusable panel still contains the prior order. Put identity before state in the message so a reviewer does not diagnose the value before checking its owner.

When a locator intentionally supports several candidates, preserve the candidate identities in a bounded observation and state which one the condition selected. Do not dump every row's text into CI. Stable keys and a small count are enough to show whether filtering happened. If the application exposes no reliable identity at the rendered boundary, the condition cannot manufacture one. The product team must provide an observable association or the test must move the assertion to a boundary that owns the identity.

For an established framework, land the observation contract and redaction rules before changing failure text. Then add controlled fixtures for correct identity, wrong identity, missing identity, terminal state, and timeout. Update report parsers and snapshot assertions before migrating production waits, because exact-message tests and dashboards that scrape the first line will break first. Migrate the waits that currently require a rerun, then leave simple built-in conditions alone. The rollout is working when each deliberate fixture produces a different classification and a reviewer can name the target, final state, and failure owner from the first attempt.

Richer polling has a command cost. A condition that performs one lookup, reads text, and reads two attributes can issue four remote WebDriver commands per evaluation. On a Grid, shortening the polling interval multiplies those calls even when the application state has not changed. Record only fields that separate a decision, and cache nothing beyond one evaluation. If the extra observations overload the test environment, lengthen the interval or move stable identity into the same product-owned element rather than dropping identity from the diagnostic.

The page or domain owner defines which state is terminal and which identity owns it. The automation-framework owner implements condition lifecycle, exception policy, and message formatting. The CI or observability owner controls redaction, retention, and report parsing. A useful handoff contains the expected and observed identities, candidate and visibility counts, final state, bounded state changes, exception cause, locator scope, test attempt ID, and the artifact path. It should also say whether the terminal rule came from a product contract or only from a test assumption.

Polling diagnostics do not catch every state the page passed through. A condition can sample PROCESSING, miss a brief ERROR between polls, and later see READY. If that transient error matters, the product must expose durable history or the test needs event or backend evidence. A more detailed final timeout message cannot reconstruct a transition that no evaluation observed.

Roll diagnostic waits into an established framework

Begin with the ten timeout messages that force the team to rerun a test just to learn what happened. Do not replace every built-in ExpectedCondition. Visibility, URL, title, and element-count helpers are readable when their default descriptions already answer the question.

For each candidate, write down the domain identity, expected state, last useful observation, terminal failure states, and transient exceptions. If reviewers cannot name those fields, the wait may be compensating for an unclear product contract.

Create a small package of typed conditions. Keep constructors explicit, return a useful value, override toString(), and expose a lazy message method only when the caller needs withMessage(Supplier). Test each class against a controlled page that exercises success, timeout, missing element, and terminal failure.

Test the message as a contract fragment, not as a complete stack trace. A timeout test can assert that it contains the order ID, expected state, and final observed state. A terminal-state test should assert that the unignored exception escapes before the timeout. A missing-element test should prove that the condition records the absence while WebDriverWait continues according to its default policy.

Use deliberately short durations only in these condition contract tests, and make clear that they are test inputs. Browser end-to-end timeouts should come from the application behavior and environment. Copying a 50 millisecond fixture timeout into a production flow creates a different failure problem.

Add a concurrency test if the factory or dependency injection setup might cache condition objects. Launch two waits with separate instances and distinct order IDs, then verify that neither message contains the other's state. The better design is still construction per invocation; the test protects that wiring from a future singleton refactor.

Do not force all callers through one shared WebDriverWait instance. withMessage(), timeout, interval, and ignored exceptions are mutable configuration. Reusing the instance can leak one condition's policy into the next. A factory may create configured waits, but each invocation should own its condition and message.

Make redaction part of code review. Status, row counts, and synthetic order IDs are generally useful. Email addresses, payment fragments, access tokens, and entire HTML blocks do not belong in CI exception text. Provide an artifact path for sensitive debugging rather than dumping it into logs.

Run focused contract tests without suite retries and retain Surefire reports:

YAML
name: selenium-wait-contracts
on:
  pull_request:
    paths:
      - "src/test/java/**"
      - "pom.xml"

jobs:
  expected-conditions:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - run: mvn -B -Dtest="*ConditionTest" -Dheadless=true test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: wait-diagnostics
          path: |
            target/surefire-reports
            target/wait-artifacts

The sample paths and headless property must match the project's fixture. A workflow cannot create diagnostics that the test code never writes.

During rollout, compare classification time, not just failure count. The goal is that a failure tells a reviewer whether the element was missing, the state was terminal, the wrong query rendered, or the transition was still progressing. Do not invent a percentage improvement without collecting it from actual incidents.

Review message stability after Selenium upgrades. Duration formatting and driver metadata can change. Assert that messages contain the condition identity and last observation, not a byte-for-byte copy of the complete exception.

What diagnostic conditions cost, and when not to add one

Stateful conditions contain more code than a lambda. They need unit or fixture tests, redaction, ownership, and careful exception policy. The extra code is justified for domain transitions that regularly fail without evidence. It is wasteful for a simple visible heading.

Polling can add remote calls. Reading text, several attributes, and child elements every 100 milliseconds is expensive on a Grid and can load the browser. Capture only fields needed for the decision, and choose an interval that respects both responsiveness and infrastructure cost.

A condition can accidentally encode implementation details. Waiting for four internal phases when the user contract is simply “report is ready” couples tests to an orchestration design. Prefer a product-owned state or visible outcome. Keep lower-level phases for a targeted component test.

Do not use a custom condition to hide a terminal error. If the application says DECLINED, fail at once with that state. Waiting for READY until timeout wastes time and makes the report less precise.

Avoid it when an event-driven assertion or direct API fixture is the correct boundary. Selenium polling is appropriate for browser-observable state. It should not query a database on every poll or become a service health monitor.

Do not share mutable diagnostic instances. One condition per wait keeps poll history, identity, and failure evidence aligned. Parallel reuse creates misleading messages even if the underlying WebDriver calls happen to succeed.

Finally, leave built-in conditions in place when they already communicate the requirement. Replacing urlContains("/receipt") with a hundred-line wrapper adds complexity without new evidence. Custom code earns its maintenance cost only when it preserves a fact the ordinary timeout would otherwise lose.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 7, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

How do I add a useful message to WebDriverWait?

Use withMessage(String) for fixed context or withMessage(Supplier<String>) when the message must include state gathered during polling. The supplier is evaluated on timeout, so it can report the condition's final observation.

Why does my custom ExpectedCondition print a class name and hash?

The default timeout description uses the condition object's string representation. Override toString() with the expected domain state and current observation, or provide an explicit message supplier on the wait.

Should an ExpectedCondition catch every WebDriverException?

No. Let unignored exceptions terminate the wait because invalid sessions, script failures, and many driver errors are not evidence that the condition is merely false. Handle only a transient exception that the condition's contract explicitly permits.

Which exception does WebDriverWait ignore by default?

Java WebDriverWait ignores NotFoundException and its subclasses while polling. Other exceptions propagate unless the wait is configured to ignore them, so broad ignore lists can turn immediate defects into vague timeouts.

Can one diagnostic condition instance be shared across parallel tests?

Avoid sharing it when it stores poll counts or last-observed values. FluentWait makes no thread-safety guarantee, and mutable diagnostic state should belong to one wait invocation and one test identity.