Geb Tips & Tricks

Craig Atkinson | Chief Technologist | Object Partners

About Me

  • Craig Atkinson
  • Chief Technologist, Object Partners (OPI)
  • Using Geb 4 past years
  • Very minor Geb contributor

Agenda

  • Stable tests in CI environments
  • Alternative approach to page objects
  • Complex UI elements
  • Cross-browser testing
  • Running tests in parallel
  • Remote controls
  • Mock 3rd-party services

Stable tests in CI

  • Frustrating when tests pass locally but fail in CI
  • Failures tougher to debug

Timing issues

  • CI machines often slower than developer laptops
  • Dialogs that open or close slowly, dynamic content rendering, etc.
  • Geb has built-in waiting support

Geb waiting support

  • Wait for elements to be visible, page content to change, etc.
  • Can also wait for data changes (new DB record created, etc.)
  • waitFor method available in tests and page objects
  • Waits for any closure to return true

Waiting examples


  waitFor {
    $("div.alert").displayed
  }
  
  assert $("div.alert").text() == "Error creating user"
  

  waitFor {
    databaseUtil.userExists('myUsername')
  }
  
  loginPage.login('myUsername', 'myPassword')
  

Test failure screenshots

  • Usually can't watch browser test run on CI server
  • PNG screenshot and HTML dump when test finishes
  • Have tests extend GebReportingSpec
  • Configure CI build to archive screenshots & HTML

Screenshot configuration

GebConfig.groovy


  // Configure where screenshots are stored
  reportsDir = 'build/test-reports'
  
  // Optional, less noise when only failed tests generate screenshots
  reportOnTestFailureOnly = true
  

Javascript errors

  • Often have logs for server errors
  • More code on front-end with newer frameworks like Angular, React, etc.
  • What about Javascript errors?

Capture browser logs


  def cleanup() {
    LogEntries logs = driver.manage().logs().get(LogType.BROWSER)
    List errorLogEntries = logs.filter(Level.SEVERE)
    
    if (errorLogEntries) {
      println "Browser errors:"
      
      errorLogEntries.each { logEntry ->
        println("[${logEntry.level}] ${logEntry.message}")
      }
    }
  }
  

Browser log example


  // Intentional Javascript error for demonstrating capturing browser logs
  fakeObject.fakeMethod();
  

  Browser errors:
  [SEVERE] /author/edit/4 59:13 Uncaught ReferenceError: fakeObject is not defined
  

Browser logging gotchas

  • Not supported by Internet Explorer
  • For safety, wrap logging code in try/catch

Headless CI

Page Objects

Page Object pattern

  • Abstract page-specific details into helper classes (Page Objects)
  • Re-used across tests
  • Single point of maintenance
  • Tests become easier to read

Page Object Example


  class LoginPage extends geb.Page {
    static content = {
      usernameField { $("#username") }
      passwordField { $("#password") }
      
      submitButton(to: DashboardPage) { $("#submit") }
    }
  }
  

Using page objects in test

Standard: Geb delegates method calls to current page object

Test with standard page objects


  to HomePage
  loginButton.click()
  
  username = "user1"
  password = "password1"
  submitButton.click()
  
  // What page is the test on now?
  
  // What fields are on the current page?
  

Alternative approach to page objects

Strongly-typed page objects

  • Test uses instance of current page object
  • Page objects have methods that resemble user actions
  • Methods that change page return instance of new page
  • Tests even easier to read
  • IDE autocomplete available on pages in tests

Test using typed pages


  HomePage homePage = to(HomePage)
  
  LoginPage loginPage = homePage.clickLoginButton()
  
  DashboardPage dashboardPage = loginPage.login("user1", "password1")
  

Chain page calls


  HomePage homePage = to(HomePage)
  
  DashboardPage dashboardPage = homePage
    .clickLoginButton()
    .login("user1", "password1")
    

Typed home page


  class HomePage extends geb.Page {
    static content = {
      loginButton(to: LoginPage) { $("#loginButton") }
    }
    
    LoginPage clickLoginButton() {
      loginButton.click()
      
      return browser.page
    }
  }
  

Typed login page


  class LoginPage extends geb.Page {
    static content = {
      usernameField { $("#username") }
      passwordField { $("#password") }
      submitButton(to: DashboardPage) { $("#submit") }
    }
    
    DashboardPage login(String username, String password) {
      usernameField.value(username)
      passwordField.value(password)
      submitButton.click()
      
      return browser.page
    }
  }
  

Typed page objects summary

  • Some additional work to write page objects
  • Save much more time when writing (and reading) test cases

Complex UI elements

Mouse interactions

Click and drag slider with mouse


  static content = {
    ratingSliderHandle { $(".ui-slider-handle") }
  }
  
  void moveRatingSlider(Integer rating) {
    // Slider is 400 pixels wide and starts at 1,
    // so each notch above 1 is 100 pixels apart
    Integer numPixelsX = (rating - 1) * 100
    
    interact {
      clickAndHold(ratingSliderHandle)
      moveByOffset(numPixelsX, 0)
      release()
    }
  }
  

Mouse interaction demo

Use keyboard

Send keystrokes with left-shift operator <<


  $("#myInputField") << "value"
  

  $(".ui-slider-handle") << Keys.ARROW_RIGHT
  

Keyboard demo

Execute raw Javascript

  • Last-ditch effort with complex UI controls
  • All Navigator elements have a 'jquery' field to run JS

Show hidden element


  static content = {
    hiddenLink { $("#hiddenLink") }
  }
  
  void clickHiddenLink() {
    hiddenLink.jquery.show()
    
    hiddenLink.click()
  }
  

Cross-Browser Testing

Available browsers

  • Any browser with a Selenium/Webdriver library
  • Real: Firefox, Chrome, IE, Safari, etc.
  • Simulated: HtmlUnit, PhantomJS

Configuring browser in Geb

  • Browser Selenium driver dependency
  • Additional OS-specific browser driver
  • Section in GebConfig.groovy
  • Pass environment parameter to Geb

Selenium driver dependencies


  def seleniumVersion = "2.53.1"
  
  testCompile "org.seleniumhq.selenium:selenium-chrome-driver:${seleniumVersion}"
  testCompile "org.seleniumhq.selenium:selenium-firefox-driver:${seleniumVersion}"
  testCompile "org.seleniumhq.selenium:selenium-ie-driver:${seleniumVersion}"
  

Operating system driver

  • Chrome & Internet Explorer need local driver executable
  • Firefox <= 47 doesn't need one, Firefox >= 48 does
  • Manually download, install, and configure
  • Or ...

Automatic download

WebdriverManager dependency


  testCompile("io.github.bonigarcia:webdrivermanager:1.4.1")
  

Chrome GebConfig Example


  import io.github.bonigarcia.wdm.ChromeDriverManager
  import org.openqa.selenium.chrome.ChromeDriver
  
  environments {
    chrome {
      ChromeDriverManager.getInstance().setup()
      
      driver = { new ChromeDriver() }
    }
  }
  

WebDriverManager configuration

  • Drivers downloaded to ~/.m2/repository/webdriver by default
  • Download location and many other parameters are configurable via config file or system properties

Example config file


  # src/test/resources/application.properties
  
  wdm.targetPath=build/webdriver
  

Pass geb.env parameter

Grails 2


  grails -Dgeb.env=chrome test-app functional:
  

Grails 3


  gradle -Dgeb.env=chrome integrationTest
  

Spring Boot


  gradle -Dgeb.env=chrome test
  

Tweak Grails 3 Gradle file


  // Pass system properties through to the integrationTest task
  // so we can pass in the 'geb.env' property
  configure(integrationTest) {
    systemProperties System.properties
  }
  

Tweak Spring Boot Gradle file


  // Pass system properties through to the test task
  // so we can pass in the 'geb.env' property
  configure(test) {
    systemProperties System.properties
  }
  

Parallel testing

Parallel testing requirements

  • Build tool that supports parallel testing (Gradle, etc.)
  • Tests can't modify any shared data
  • Safest to have each test set up its own data

Parallel testing example

  • Using Gradle
  • Start app using Grails wrapper
  • Run 2 tests simultaneously
  • Shut down Grails app
  • Demo

Groovy Remote Control

What are remote controls useful for?

  • When tests run in separate JVM from application
  • Setting up data specific to a test
  • Grabbing data after test for verification
  • Retrieving & setting up data in mock services (stay tuned)

How does it work?

Data setup example


  Idea createIdea(String title, String description) {
    RemoteControl remote = new RemoteControl()
    
    remote {
      Idea idea = new Idea(
          title: title,
          description: description
      )
      
      idea.save()
    }
  }
  

  List ideas = (1..5).collect { i ->
    ideaRemoteControl.createIdea("Title ${i}", "Description ${i}")
  }
  

Data verification example


  Idea findByTitle(String title) {
    RemoteControl remote = new RemoteControl()
    
    remote {
      Idea.findByTitle(title)
    }
  }
  

Grails 3 injection

Tests and app run in same JVM


  import grails.test.mixin.integration.Integration
  import org.springframework.beans.factory.annotation.Autowired
  
  @Integration
  class AuthorGebSpec extends GebReportingSpec {
  
      @Autowired
      AuthorDataUtil authorDataUtil
      
      void "should create Author"() {
        ...
        
        then:
        assert authorDataUtil.findByLastName('Last')?.firstName == 'First'
      }
  }
  

Mock third-party services

Proliferation of Third-party services

  • Email, payment processing, storage, address verification, etc.
  • Great for productivity, but can complicate testing
  • Don't want our tests dependent on services out of our control

Solution?

  • Mock request/response from external services
  • Our tests not dependent on external service
  • Tests have tight control over service responses

Test-specific Dependency Injection

  • When using DI framework like Spring, Guice, etc.
  • Create mock version of code that calls external service
  • Use DI to replace code with mock version during functional tests
  • Setup and verify data from mock services in tests

Example service to mock

PatentService sends ideas to patent office


  class PatentService {
  
    def sendToPatentOffice(Idea idea) {
      // Send the idea to the real patent office
      
    }
    
  }
  

Mock Patent Service


  class PatentServiceMock extends PatentService {
    List ideasSentToPatentOffice = []
    
    @Override
    def sendToPatentOffice(Idea idea) {
      ideasSentToPatentOffice << idea
    }
  }
  

Use mock patent service

  • Replace PatentService instance with mock version
  • In Grails, grails-app/conf/spring/resources.groovy

  beans = {
  
    if (Environment.current == Environment.TEST) {
      // Override the PatentService with our mock version when running tests
      patentService(PatentServiceMock)
      
    }
  }
  

Test that hits mock service


  @Autowired
  PatentServiceMock patentService
  
  def 'should submit idea to patent office'() {
    given:
    Idea idea = ideaRemoteControl.findByTitle('Patentable Idea')
    
    IdeaShowPage ideaShowPage = to([id: idea.id], IdeaShowPage)
    
    when:
    ideaShowPage = ideaShowPage.submitIdeaToPatentOffice()
    
    then:
    List ideasSubmittedToPatentOffice = patentService.ideasSentToPatentOffice
    
    assert ideasSubmittedToPatentOffice*.id.contains(idea.id)
  }
  

Wrapping up

Summary

  • Stable tests in CI environments
  • Alternative approach to page objects
  • Complex UI elements
  • Cross-browser testing
  • Running tests in parallel
  • Data access with remote controls
  • Mock 3rd-party services

Resources

Contributing to Geb

Q&A

  • @craigatk1
  • craig.atkinson@objectpartners.com