Geb Page Objects and Modules: Building Maintainable Browser Tests
Raw $() selectors scattered throughout test files break the moment your UI changes. Geb's Page Object support is built into the framework — not a convention bolted on top — and it gives you a structured way to isolate selectors, model navigation flows, and reuse UI components.
The Page Class
A Geb Page represents one screen in your application. It defines:
url— the path to navigate to this pageat checker— condition that must be true when you're on this pagecontent— named selectors for elements on the page
import geb.Page
class LoginPage extends Page {
static url = '/login'
static at = { title == 'Sign In — MyApp' }
static content = {
emailField { $('input[name=email]') }
passwordField { $('input[name=password]') }
submitButton { $('button[type=submit]') }
errorMessage(required: false) { $('div.alert-error') }
}
void loginAs(String email, String password) {
emailField.value(email)
passwordField.value(password)
submitButton.click()
}
}Using the Page in Tests
import geb.spock.GebSpec
class LoginSpec extends GebSpec {
def "valid credentials redirect to dashboard"() {
given:
to LoginPage
when:
loginAs('alice@example.com', 'password123')
then:
at DashboardPage
}
def "invalid credentials show error"() {
given:
to LoginPage
when:
loginAs('alice@example.com', 'wrongpassword')
then:
waitFor { errorMessage.displayed }
errorMessage.text().contains('Invalid credentials')
}
}to LoginPage navigates to the page's URL and verifies the at checker. at DashboardPage asserts you've arrived at the expected page.
Content DSL Options
The content {} block supports several options that control how elements behave:
static content = {
// required: false — don't fail if element is absent
flashMessage(required: false) { $('div.flash') }
// wait: true — wait for element to appear before returning
resultsTable(wait: true) { $('table.results') }
// cache: true — don't re-evaluate the selector on each access
pageHeader(cache: true) { $('h1') }
// wait with timeout
loadingSpinner(wait: 10, required: false) { $('div.spinner') }
// parameterized content
rowByIndex { index -> $('table tr', index + 1) } // skip header row
cellByText { text -> $('td', text: text) }
}Parameterized content lets you pass arguments to selectors:
// In test:
page.rowByIndex(2).click()
page.cellByText('Pending').text()The Module Class
Modules model reusable UI components — navigation bars, form sections, data table rows, modals — that appear on multiple pages.
import geb.Module
class NavbarModule extends Module {
static content = {
logo { $('a.navbar-brand') }
searchInput { $('input[name=search]') }
userMenu { $('div.user-menu') }
logoutLink { userMenu.$('a[href=/logout]') }
}
void searchFor(String term) {
searchInput.value(term)
searchInput << Keys.ENTER
}
}Use a module in a page by including it in the content block:
class DashboardPage extends Page {
static url = '/dashboard'
static at = { currentUrl.contains('/dashboard') }
static content = {
navbar { module NavbarModule }
projectList { $('ul.projects') }
newProjectButton { $('a.new-project') }
}
}// In test:
def "user can search from dashboard"() {
given:
to DashboardPage
when:
navbar.searchFor('testing tools')
then:
at SearchResultsPage
}Nested Modules
Modules can contain other modules. This is useful for table rows that have repeating sub-components:
class TableRowModule extends Module {
static content = {
nameCell { $('td', 0) }
statusCell { $('td', 1) }
actionMenu { module ActionMenuModule, $('td', 2) }
}
}
class DataTablePage extends Page {
static content = {
rows { moduleList TableRowModule, $('table tbody tr') }
}
}moduleList creates a list of modules, one per matched element:
def "table shows all active projects"() {
given:
to DataTablePage
expect:
rows.size() == 5
rows[0].nameCell.text() == 'Project Alpha'
rows[0].statusCell.text() == 'Active'
}Page Navigation and At Checking
Geb provides multiple navigation methods:
// Navigate and verify at checker passes
to LoginPage
// Navigate without at check
via LoginPage
// Assert current page (verifies at checker)
at DashboardPage
// Navigate and return page instance
def page = browser.to(LoginPage)Pages can define where they expect to navigate after an action:
class LoginPage extends Page {
static content = {
submitButton { $('button[type=submit]') }
}
DashboardPage loginAs(String email, String password) {
emailField.value(email)
passwordField.value(password)
submitButton.click()
browser.at(DashboardPage)
}
}Organizing Page Objects
A clean structure for mid-size projects:
src/test/groovy/
pages/
LoginPage.groovy
DashboardPage.groovy
SettingsPage.groovy
modules/
NavbarModule.groovy
ModalModule.groovy
DataTableModule.groovy
specs/
LoginSpec.groovy
DashboardSpec.groovyKeep pages in pages/, modules in modules/, and tests in specs/. Page objects should never contain assertions — assertions live in specs.
Common Mistakes
Assertions in page objects: Page objects should model the UI, not assert on it. Assertions belong in the spec's then: block. This makes page objects reusable across specs with different expected outcomes.
Over-caching: cache: true means Geb won't re-query the DOM. Use it for static elements (headers, labels). Don't cache dynamic content — you'll read stale values.
Missing required: false: Elements that are conditionally present (error messages, loading indicators, empty states) need required: false. Otherwise Geb throws if they're absent when the test accesses them.
Summary
Geb's Page Objects and Modules provide a structured way to manage the growing complexity of browser test suites. Pages encapsulate navigation and selectors for a single screen. Modules encapsulate reusable components. Together they keep selectors in one place — meaning a UI change requires updating one file, not hunting through thirty test files.
The content DSL options (wait, required, cache) give you control over how elements are located, making tests more reliable without adding boilerplate waiting logic to every test.