Testing Dynamic Content with Geb: waitFor, AJAX, and Animations

Testing Dynamic Content with Geb: waitFor, AJAX, and Animations

Static pages are easy to test. The real challenge is dynamic content — elements that appear after AJAX calls, modals that animate in, infinite scroll that loads more rows, dropdowns that require hover states. Geb has solid tools for handling all of these, but you need to understand how they work to use them correctly.

Understanding waitFor

waitFor is Geb's polling mechanism. It evaluates a closure repeatedly until it returns truthy or the timeout expires:

// Wait up to the default timeout (from GebConfig)
waitFor { $('div.results').displayed }

// Custom timeout in seconds
waitFor(20) { $('div.results li').size() > 0 }

// Custom timeout and polling interval
waitFor(20, 0.25) { $('span.status').text() == 'Complete' }

// With a failure message
waitFor('Search results did not appear') { $('div.results').displayed }

waitFor throws WaitTimeoutException if the condition isn't met in time, with the last evaluation's exception included in the message.

What waitFor Evaluates

The closure is evaluated as a Groovy truth check:

  • null → false
  • false → false
  • Empty string '' → false
  • Empty collection [] → false
  • Any non-null, non-false, non-empty value → true
// These all work correctly with waitFor:
waitFor { $('div.spinner').present == false }  // wait for element to disappear
waitFor { $('ul.results li').size() >= 5 }     // wait for count
waitFor { $('input.loaded').value() }           // wait for non-empty value

AJAX Content

Basic AJAX Wait

def "search returns results"() {
    given:
    to SearchPage

    when:
    searchInput.value('Geb testing')
    searchButton.click()

    then: "results load after AJAX call completes"
    waitFor { resultsContainer.displayed }
    results.size() > 0
}

Waiting for Spinner to Disappear

Many apps show a loading spinner during AJAX requests. Wait for it to disappear before asserting:

def "data table loads after filter"() {
    given:
    to DataTablePage

    when:
    filterDropdown.value('Active')

    then: "loading indicator appears then disappears"
    waitFor { loadingSpinner.displayed }    // wait for loading to start
    waitFor { !loadingSpinner.displayed }   // wait for loading to finish
    tableRows.size() > 0
}

Polling for Specific AJAX Response

When you need to wait for a specific value from an AJAX response:

def "status updates via polling"() {
    given:
    to JobStatusPage
    startJobButton.click()

    when: "wait for job completion"
    waitFor(60) { statusBadge.text() in ['Complete', 'Failed'] }

    then:
    statusBadge.text() == 'Complete'
}

Animated Modals

Modals often have CSS animations. The element exists in the DOM before it's visible. Test displayed not present:

class ModalModule extends Module {
    static content = {
        modal(wait: true) { $('div.modal') }
        modalTitle { modal.$('h4.modal-title') }
        confirmButton { modal.$('button.btn-confirm') }
        closeButton { modal.$('button.btn-close') }
    }

    boolean isOpen() {
        modal.displayed
    }
}

def "delete confirmation modal works"() {
    given:
    to ItemsPage

    when:
    deleteButton.click()

    then: "modal opens"
    waitFor { confirmModal.isOpen() }
    confirmModal.modalTitle.text() == 'Confirm Delete'

    when: "user confirms"
    confirmModal.confirmButton.click()

    then: "modal closes and item is removed"
    waitFor { !confirmModal.isOpen() }
    itemList.size() == originalCount - 1
}

Alert Dialogs

For native browser alert(), confirm(), and prompt():

import org.openqa.selenium.support.ui.ExpectedConditions
import org.openqa.selenium.support.ui.WebDriverWait
import java.time.Duration

def "confirm dialog is handled"() {
    given:
    to DeletePage

    when:
    deleteButton.click()
    def wait = new WebDriverWait(driver, Duration.ofSeconds(5))
    def alert = wait.until(ExpectedConditions.alertIsPresent())
    alert.accept()

    then:
    waitFor { successMessage.displayed }
}

Infinite Scroll

Testing infinite scroll requires triggering the scroll event and waiting for new content:

class InfiniteListPage extends Page {
    static content = {
        itemList { $('ul.items li') }
        loadMoreTrigger(required: false) { $('div.load-more-trigger') }
    }

    void scrollToBottom() {
        js.exec('window.scrollTo(0, document.body.scrollHeight)')
    }

    int loadMoreItems(int targetCount, int maxScrolls = 10) {
        int scrolls = 0
        while (itemList.size() < targetCount && scrolls < maxScrolls) {
            scrollToBottom()
            waitFor(5) { itemList.size() > scrolls * 10 }
            scrolls++
        }
        itemList.size()
    }
}

def "infinite scroll loads more items"() {
    given:
    to InfiniteListPage
    def initialCount = itemList.size()

    when:
    scrollToBottom()

    then:
    waitFor(10) { itemList.size() > initialCount }
}

Drag and Drop

import org.openqa.selenium.interactions.Actions

def "drag card to different column"() {
    given:
    to KanbanPage
    def source = $('div.card[data-id=123]')
    def target = $('div.column[data-status=done]')

    when:
    def actions = new Actions(driver)
    actions.dragAndDrop(source.firstElement(), target.firstElement()).perform()

    then:
    waitFor { target.$('div.card[data-id=123]').present }
}

Hover and Tooltips

Elements that only appear on hover need explicit hover actions:

import org.openqa.selenium.interactions.Actions

def "tooltip appears on hover"() {
    given:
    to DataPage

    when:
    def actions = new Actions(driver)
    actions.moveToElement($('span.info-icon').firstElement()).perform()

    then:
    waitFor { $('div.tooltip').displayed }
    $('div.tooltip').text().contains('Click to expand')
}

File Upload

def "file upload shows preview"() {
    given:
    to UploadPage

    when:
    def fileInput = $('input[type=file]')
    fileInput.value('/path/to/test-image.jpg')

    then: "preview appears after upload"
    waitFor { imagePreview.displayed }
    imagePreview.getAttribute('src').contains('test-image')
}

Configuring Timeouts Per Environment

Different environments have different performance characteristics. Configure timeouts accordingly:

// GebConfig.groovy
environments {
    ci {
        waiting {
            timeout = 20         // CI can be slow
            retryInterval = 0.5
        }
    }

    local {
        waiting {
            timeout = 10         // Local is faster
            retryInterval = 0.25
        }
    }
}

For specific slow operations, override inline:

waitFor(30) { heavyReportGenerated.displayed }  // this specific step needs more time

Common Pitfalls

waitFor inside then: block: Groovy's then: block is special — it wraps everything in assertions. Don't nest waitFor calls. Use waitFor in when: or expect: blocks when you can, or be aware that a WaitTimeoutException in then: looks like an assertion failure.

Asserting immediately after click: JavaScript events are async. A click that triggers an AJAX call won't have results by the next line. Always waitFor the result.

Using Thread.sleep: It wastes time when the condition is already met and fails when the system is slow. Replace every Thread.sleep(2000) with waitFor(5) { condition }.

Testing spinner appearance: In fast environments, the spinner may never appear (the AJAX call completed before Geb checked). It's safer to wait for the final state directly rather than waiting for an intermediate loading state.

Summary

Dynamic content testing with Geb comes down to one principle: never assume the DOM state. After any user interaction that triggers async behavior, use waitFor to wait for the expected result before asserting. The waiting configuration in GebConfig.groovy sets sensible defaults; inline waitFor(seconds) handles the exceptions.

Get this right, and your Geb tests will be reliable across environments with different network speeds and machine performance.

Read more

Start now free