geb-testing
Geb Testing Skill
Generates and reviews Geb + Spock browser automation specs following intermediate-level best practices.
Geb documentation is at https://groovy.apache.org/geb/manual/current/.
Core Principles
- Always use Page Objects — never inline selectors in specs.
- Use
atcheckers — every Page Object must declarestatic at. - Prefer CSS selectors over XPath; avoid brittle nth-child selectors.
- Use
waitForexplicitly for dynamic content; neverThread.sleep.- Strongly prefer to put
waitFormethod calls into methods inside classes that extendgeb.Page,geb.Module, or other shared test fixtures. If clicking or interacting with some object on a page requires waiting for some response, it's best to encapsulate that knowledge into a method on the pageObject (e.g.deleteTableRoworsubmitForm) that then waits for the signal that the operation is complete. That will make the knowledge of your page more reusable and avoid repeatedwaitFor.
- Strongly prefer to put
- Use
GebReportingSpec(notGebSpec) so screenshots are captured on failure. - Keep specs in
given/when/thenblocks — one behaviour per feature method.- Caveat: if a behaviour requires multiple steps, it's okay to have multiple
when/thenblock pairs within one test method, so long as each intermediary step is integral to the journey under test.
- Caveat: if a behaviour requires multiple steps, it's okay to have multiple
- Use Modules for repeated UI components (nav bars, modals, form rows).
- Avoid writing flaky tests by stress-testing your Geb spec before committing:
- Try introducing network latency to verify that a test will pass on a slow network.
- Try re-running tests many times in succession locally to ensure they aren't flaky
Spec File Structure
package com.example.specs
import geb.spock.GebReportingSpec
import com.example.pages.LoginPage
import com.example.pages.DashboardPage
import spock.lang.Stepwise // only when steps truly depend on each other
@Stepwise // omit if steps are independent
class LoginSpec extends GebReportingSpec {
def "successful login redirects to dashboard"() {
given: "user is on the login page"
to LoginPage
when: "valid credentials are submitted"
loginForm.username = "user@example.com"
loginForm.password = "secret"
loginForm.submit()
then: "the dashboard is displayed"
at DashboardPage
welcomeMessage.text() == "Welcome, user@example.com"
}
def "login fails with invalid credentials"() {
given:
to LoginPage
when:
loginForm.username = "bad@example.com"
loginForm.password = "wrong"
loginForm.submit()
then:
at LoginPage
errorMessage.displayed
errorMessage.text().contains("Invalid credentials")
}
}
Page Object Structure
package com.example.pages
import geb.Page
import com.example.modules.LoginFormModule
class LoginPage extends Page {
// `at` checker — verified when `to` or `at` is called
static at = { title == "Login | MyApp" }
// URL for `to LoginPage`
static url = "/login"
static content = {
// Lazy by default; set required: false for optional elements
loginForm { module(LoginFormModule) }
errorMessage(required: false) { $(".alert-error") }
}
}
class DashboardPage extends Page {
static at = { $("h1.dashboard-title").displayed }
static url = "/dashboard"
static content = {
welcomeMessage { $(".welcome-msg") }
navBar { module(NavBarModule) }
}
}
Module Structure
Use modules for components that repeat across pages (nav, modal, form row, table row).
package com.example.modules
import geb.Module
class LoginFormModule extends Module {
static content = {
username { $("input[name='username']") }
password { $("input[name='password']") }
submitBtn { $("button[type='submit']") }
}
void submit() {
submitBtn.click()
}
}
waitFor Patterns
// Wait for element to appear (default timeout from GebConfig)
waitFor { $(".spinner").not(".active") }
// Wait with custom timeout (seconds)
waitFor(10) { successBanner.displayed }
// Wait and return the element
def result = waitFor { $(".result-row") }
// Use in content DSL for elements that load dynamically
static content = {
// Wait for this element every time it's accessed
asyncTable(wait: true) { $("table.results") }
}
Never use
Thread.sleep. IfwaitForkeeps timing out, make sure your assertion is actually eventually true (you may be relying on an unreliable side effect), ensure that your page is completing its work as quickly as it can, or increase the timeout inGebConfig.groovyrather than sleeping.
Selector Best Practices
| ✅ Prefer | ❌ Avoid |
|---|---|
$("input[name='email']") |
$("div > div:nth-child(2) > input") |
$(".submit-btn") |
$("button", text: "Submit") (fragile for i18n) |
$("table.results tbody tr") |
XPath selectors |
data-testid attributes |
Position-based selectors |
If the app doesn't have data-testid attrs, use stable semantic selectors: name, id, aria-label, role.
Common Patterns
Checking a list of items
def rows =
rows.size() == 3
rows[0].find("td.name").text() == "Alice"
Filling a form
$("input[name='firstName']").value("Jane")
$("select[name='country']").value("US")
$("input[type='checkbox'][name='agree']").value(true)
Interacting with dropdowns (non-native)
// For custom JS dropdowns (not <select>)
$(".dropdown-trigger").click()
waitFor { $(".dropdown-menu").displayed }
$(".dropdown-menu li", text: "Option B").click()
Asserting navigation
// Checks URL and `at` checker
at ConfirmationPage
currentUrl.contains("/confirmation")
Anti-Patterns to Avoid
@Stepwiseoveruse — only use when steps share state (e.g., a multi-step wizard). Independent scenarios should be separate specs or usesetup()/cleanup().- Assertions inside
when:blocks — lines afterthen:orexpect:are automatically asserted - no need to even supply theassertkeyword! sleep()anywhere in test code.- Hard-coded absolute URLs — use relative paths; base URL lives in
GebConfig.groovy. - Accessing the DOM directly in specs — all
$()calls belong in Page Objects or Modules. - Overusing
required: falseon optional content - the only time you really want to mark a page element asrequired: falseis when your spec needs to try to interact with it when it's absent (for example, to assert that it isn't present,!page.buttons.sometimesThereButton). If the button may or may not be there, but you never test the case where it isn't there, you should just leave it as required. Remember, throwing an exception when something exceptional happens is okay, especially in tests!
Avoiding creating flaky tests
Browser testing frameworks like Geb sometimes get a bad reputation for being flaky, but with some diligence you can keep your Geb tests more reliable!
Introduce Network latency
Adding even a small amount of network latency to your test can expose places where you probably want to add a waitFor statement to your page object. Drop some code like this in your test's setup() method and run it a few times to make sure it consistently works:
def networkLatency = java.time.Duration.ofMillis(500)
browser.networkLatency = networkLatency
Introduce reruns
While developing your tests, try doing a pass of re-running the test locally to make sure it consistently passes. If each of your tests only fails 1 time out of a hundred, but you have ten tests, your build pipelines are going to be flakier than your team can tolerate.
If you're using geb-spock, use the RepeatUntilFailure annotation with a maxAttempts value to run the test locally. You probably won't want to commit that annotation in most cases, but it's a good tool to verify your test is consistent before making it a blocker for someone else's PR.