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→ falsefalse→ 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 valueAJAX 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'
}Modal Dialogs
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 timeCommon 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.