January 22, 2011

#40 Code Reading - The Capybara Gem

In this post, we will take a look at the Capybara gem (version 0.4.1.1). This gem allows us to simulate a user's interaction with our web application. This will be useful for integration and acceptance testing. (Have a look at the README if you aren't familiar with this gem.)

Setting The App

Capybara will need to be given a Rack application to work with. We could manually set this, but RSpec-Rails (2.4.1) requires 'capybara/rails' which sets the app for us. This Rack application contains our Rails app.
capybara/dsl.rb, rspec-rails/lib/rspec/rails/browser_simulators.rb, capybara/rails.rb
# dsl.rb
module Capybara
  class << self
    attr_accessor :app
__________________________________________________

# from rspec-rails
# browser_simulators.rb
begin
  require 'capybara/rails'
__________________________________________________

# rails.rb
Capybara.app = Rack::Builder.new do
  map "/" do
    if Rails.version.to_f >= 3.0
      run Rails.application
    else ...
end.to_app

Establishing The DSL

If we want to create integration tests using Capybara and RSpec, we can put these tests in a spec/integration folder. By doing this, when RSpec-Rails loads up, it will configure itself to include the RSpec::Rails::RequestExampleGroup module for any tests in the spec/integration folder. (Alternatively, we could use a 'requests' folder instead of 'integration'.)

When the RSpec::Rails::RequestExampleGroup module is included, RSpec will include the Capybara module. With this, Capybara will automatically extend itself and provide the DSL methods so they can be called directly in our RSpec tests.

rspec-rails/lib/rspec/rails/example.rb, rspec-rails/lib/rspec/rails/example/request_example_group.rb; capybara/dsl.rb, capybara/session.rb
# example.rb
RSpec::configure do |c|
  ...
  c.include RSpec::Rails::RequestExampleGroup, :type => :request, :example_group => {
    :file_path => c.escaped_path(%w[spec (requests|integration)])
  }
__________________________________________________ 

# request_example_group.rb
module RSpec::Rails
  module RequestExampleGroup
    ...
    capybara do
      include Capybara
    end
__________________________________________________

# dsl.rb
module Capybara
  ...
  extend(self)

  def page
    Capybara.current_session
  end

  Session::DSL_METHODS.each do |method|
    class_eval <<-RUBY, __FILE__, __LINE__+1
      def #{method}(*args, &block)
        page.#{method}(*args, &block)
      end
    RUBY
  end
__________________________________________________

# session.rb
module Capybara
  class Session
    DSL_METHODS =
     [
      :all, :attach_file, :body, :check, :choose, :click, :click_button, :click_link,
      :click_link_or_button, :click_on, :current_path, :current_url, :drag, :evaluate_script,
      :field_labeled, :fill_in, :find, :find_button, :find_by_id, :find_field, :find_link,
      :first, :has_button?, :has_checked_field?, :has_content?, :has_css?, :has_field?,
      :has_link?, :has_no_button?, :has_no_content?, :has_no_css?, :has_no_field?,
      :has_no_link?, :has_no_select?, :has_no_selector?, :has_no_table?, :has_no_xpath?,
      :has_select?, :has_selector?, :has_table?, :has_unchecked_field?, :has_xpath?, :locate,
      :save_and_open_page, :select, :source, :uncheck, :unselect, :visit, :wait_until,
      :within, :within_fieldset, :within_frame, :within_table, :within_window
    ]

The Session Class

"The Session class represents a single user's interaction with the system." All of the DSL methods are funneled through the Session class. Some, such as #visit and #current_url, are defined in this class. Most of these methods delegate to Capybara drivers (discussed below.) Those methods that aren't defined are sent to the Capybara::Node::Document class via #method_missing.
capybara/session.rb
# session.rb
module Capybara
  class Session

    def driver
      @driver ||= begin
        ...
        Capybara.drivers[mode].call(app)
      end
    end

    def visit(url)
      driver.visit(url)
    end

    def current_url
      driver.current_url
    end
    ...
    def method_missing(*args)
      current_node.send(*args)
    end

    def document
      Capybara::Node::Document.new(self, driver)
    end

    private

    def current_node
      scopes.last
    end

    def scopes
      @scopes ||= [document]
    end

The Node Classes

When Capybara retrieves a web page from our app, it will interact with the resultant HTML via various Node classes. "Capybara::Node::Base represents either an element on a page through the subclass Capybara::Node::Element or a document through Capybara::Node::Document." Base includes three modules (Capybara::Node::Finders, Capybara::Node::Actions, and Capybara::Node::Matchers) which define various other DSL methods.

Document is almost entirely defined by Base, except for #inspect.

Element has additional methods that delegate to a driver-node object (to be discussed.) It also allows access to HTML attributes of the element.

The Simple class does not inherit from base and "is useful in that it does not require a session, an application or a driver, but can still use Capybara's finders and matchers on any string that contains HTML." The HTML string is handled via a Nokogiri::HTML::Document object. " Nokogiri (鋸) is an HTML, XML, SAX, and Reader parser. Among Nokogiri’s many features is the ability to search documents via XPath or CSS3 selectors. XML is like violence - if it doesn’t solve your problems, you are not using enough of it." (The Simple class will likely allow RSpec to use Capybara instead of Webrat matchers in view and helper specs in a future version of RSpec-Rails.)

capybara/node/base.rb + document.rb + element.rb + simple.rb
# base.rb
module Capybara
  module Node
    class Base
      attr_reader :session, :base

      include Capybara::Node::Finders
      include Capybara::Node::Actions
      include Capybara::Node::Matchers

      def initialize(session, base) ...
      # 'base' is either a driver object or a driver-node object.
      # In the case of Document, 'base' is a driver object, ie. Capybara::Driver::RackTest
__________________________________________________

# document.rb
module Capybara
  module Node
    class Document < Base
      def inspect ...
__________________________________________________

# element.rb
module Capybara
  module Node
    class Element < Base

      def click
        base.click    # 'base' here refers to a driver-node object, ie. Capybara::Driver::RackTest::Node
      end

      def tag_name
        base.tag_name
      end
      ...
__________________________________________________

# simple.rb
module Capybara
  module Node
    class Simple
      include Capybara::Node::Finders
      include Capybara::Node::Matchers

      attr_reader :native

      def initialize(native)
        native = Nokogiri::HTML(native) if ...

      def text
        native.text
      end
      ...

The Node Modules

Actions - The methods here involve finding a specific HTML element and requires the use of Finders#find. The result of #find is a Capybara::Node::Element object, and the appropriate method is then called on this object (#click, #set, etc.)

Matchers - The methods here are predicate methods (ending in '?') that check for presence or absence of elements in a Node. The two main methods #has_selector? and #has_no_selector? rely on Finders#all.

Finders - All of the public methods in Finders and the other two modules ultimately rely on the protected #find_in_base method. This method will call #find on one of the Capybara drivers (ex. Capybara::Driver::RackTest) or driver-node objects (Capybara::Driver::RackTest::Node) to receive a driver-node object representing the chuck of HTML that we are interested in. This driver-node object is specific the the driver. The object is then wrapped in a Capybara::Node::Element object in order to provide a uniform interface.

capybara/node/actions.rb + matchers.rb + finders.rb
# actions.rb
module Capybara
  module Node
    module Actions
      
      def click_link(locator)
        ...
        find(:xpath, XPath::HTML.link(locator), :message => msg).click
      end

      def fill_in(locator, options={})
        ...
        find(:xpath, XPath::HTML.fillable_field(locator), :message => msg).set(options[:with])
      end
      ...
__________________________________________________

# matchers.rb
module Capybara
  module Node
    module Matchers
      def has_selector?(*args)
        ...
        wait_conditionally_until do
          results = all(*args)

          case
          when results.empty?
            false
          when options[:between]
            options[:between] === results.size
          ...

      def has_no_selector?(*args)
        ...
        wait_conditionally_until do
          results = all(*args)

          case
          when results.empty?
            true
          when options[:between]
            not(options[:between] === results.size)
          ...
__________________________________________________

# finders.rb
module Capybara
  module Node
    module Finders
      ...
      def find(*args)
        begin
          node = wait_conditionally_until { first(*args) }
        ...

      def first(*args)
        ...
        Capybara::Selector.normalize(*args).each do |path|
          find_in_base(path).each do |node|
            if matches_options(node, options)
              return convert_element(node)
           ...

      def all(*args)
        ...
        Capybara::Selector.normalize(*args).
          map    { |path| find_in_base(path) }.flatten.
          select { |node| matches_options(node, options) }.
          map    { |node| convert_element(node) }
      end
      ....
      protected

      def find_in_base(xpath)
        base.find(xpath)    # 'base' is either a driver object or a driver-node object.
      end

      def convert_element(element)
        Capybara::Node::Element.new(session, element)    # 'element' refers to a driver-node object
      end

Selectors

Capybara relies on XPath to select elements in a Node. There are three selector types defined by Capybara - :xpath, :css, and :id. The default is :css. Custom selectors can be also be defined using Capybara.add_selector. All selectors ultimately rely on valid XPath syntax, and Capybara uses the XPath gem to help with this.

When defining a Capybara selector, the actual XPath selector is provided to #xpath via a block. Regarding the :id selector, if a selector (as a symbol) is used and is not specifically defined ('find :foo'), the #match method will return true and the :id selector will be used ('find :id, :foo').

capybara/selector.rb, capybara.rb
# selector.rb
module Capybara
  class Selector
    def xpath(&block)
      @xpath = block if block
      @xpath
    end

    def match(&block)
      @match = block if block
      @match
    end
  ...
  end
end

Capybara.add_selector(:xpath) do
  xpath { |xpath| xpath }
end

Capybara.add_selector(:css) do
  xpath { |css| XPath.css(css) }
end

Capybara.add_selector(:id) do
  xpath { |id| XPath.descendant[XPath.attr(:id) == id.to_s] }
  match { |value| value.is_a?(Symbol) }
end
__________________________________________________

# capybara.rb
...
Capybara.configure do |config|
  ...
  config.default_selector = :css
end

Default Driver - Rack::Test

Capybara provides a single DSL to interface various drivers that will interact with our app. The default driver is Capybara::Driver::RackTest. (The class that this inherits from is mainly abstract. The rack-test gem "is a small, simple testing API for Rack apps.") RackTest includes the Rack::Test::Methods module and all of the methods that it contains. If needed, we can access those methods (for example, "page.driver.basic_authorize('login', 'pw')".

Within RackTest are two nested classes. The Node class is used to wrap the Nokogiri::XML::Element objects that are produced within #find. It acts as an adapter between the Nokogiri object and Capybara::Node::Element. The second nested class is Form which is used by Node when needed. Another class, NilUploadedFile, is nested under Form and helps handle multipart forms.

capybara.rb, capybara/dsl.rb, capybara/driver/base.rb + rack_test_driver.rb
# capybara.rb
...
Capybara.register_driver :rack_test do |app|
  Capybara::Driver::RackTest.new(app)
end
__________________________________________________

# dsl.rb
module Capybara
  class << self
    def current_driver
      @current_driver || default_driver
    end

    def default_driver
      @default_driver || :rack_test
    end
__________________________________________________

# rack_test_driver.rb
class Capybara::Driver::RackTest < Capybara::Driver::Base
  include ::Rack::Test::Methods
  attr_reader :app

  alias_method :response, :last_response
  alias_method :request, :last_request

  def find(selector)
    html.xpath(selector).map { |node| Node.new(self, node) }
  end

  def body
    @body ||= response.body
  end

  def html
    @html ||= Nokogiri::HTML(body)
  end

  ### other public instance methods:
  # body (alias source), current_url, delete, follow_redirects!, get, post, process,
  # put, reset!, response_headers, status_code, submit, to_binary, visit

  ### public instance methods from Rack::Test::Methods
  # "_current_session_name, build_rack_mock_session,
  # build_rack_test_session, current_session, rack_mock_session, 
  # rack_test_session, with_session

  ### public instance methods from Rack::Test::Session via Rack::Test::Methods
  # authorize, basic_authorize, clear_cookies, delete, digest_authorize,
  # follow_redirect!, get, head, header, last_request, last_response, post,
  # put, request, set_cookie
__________________________________________________

# rack_test_driver.rb
class Capybara::Driver::RackTest < Capybara::Driver::Base
  class Node < Capybara::Driver::Node ...
    ### public instance methods:
    # [], checked?, click, find, path, select_option, selected?,
    # set, tag_name, text, unselect_option, value, visible?"
__________________________________________________

# rack_test_driver.rb
class Capybara::Driver::RackTest < Capybara::Driver::Base
  class Form < Node
    ### public instance methods:
    # multipart?, params, submit
__________________________________________________

# rack_test_driver.rb
class Capybara::Driver::RackTest < Capybara::Driver::Base
  class Form < Node
    class NilUploadedFile < Rack::Test::UploadedFile
      ### public instance methods:
      # content_type, original_filename, path

JavaScript Driver - Selenium

If we need to test the JavaScript in our app, we can set our current_driver to the javascript_driver, which is Selenium by default. Selenium is accessed through the selenium-webdriver gem. If needed, we can access the Selenium::WebDriver::Driver methods via 'page.driver.browser'.

Similar to Capybara::Driver::RackTest, Capybara::Driver::Selenium has a nested Node class that wraps the Selenium::WebDriver::Element objects generated in #find.

capybara/dsl.rb, capybara/drivers/selenium_driver.rb
# dsl.rb
module Capybara
  class << self
    def javascript_driver
      @javascript_driver || :selenium
    end
__________________________________________________

# selenium_driver.rb
class Capybara::Driver::Selenium < Capybara::Driver::Base
  attr_reader :app, :rack_server, :options

  def browser
    ...
      @browser = Selenium::WebDriver.for(options[:browser] || :firefox, options)
      ...
  end

  def find(selector)
    browser.find_elements(:xpath, selector).map { |node| Node.new(self, node) }
  end

  ### other public instance methods:
  # body, current_url, evaluate_script, execute_script, reset!, source,
  # visit, wait?, within_frame, within_window

  ### Selenium::WebDriver::Driver methods exposed through #browser
  # [], all, browser, capabilities, close, current_url, execute_script, find_element,
  # find_elements, first, get, manage, navigate, page_source, quit, ref,
  # save_screenshot, screenshot_as, script, switch_to, title, visible=, visible?,
  # window_handle, window_handles
__________________________________________________

# selenium_driver.rb
class Capybara::Driver::Selenium < Capybara::Driver::Base
  class Node < Capybara::Driver::Node
    # public instance methods:
    # [], click, drag_to, find, select_option, selected? (alias checked?),
    # set, tag_name, unselect_option, value, visible?

JavaScript Drivers - Celerity and Culerity

Celerity is an alternative JavaScript enabled driver. "Celerity is a JRuby wrapper around HtmlUnit – a headless Java browser with JavaScript support." Like the other drivers, within the Capybara::Driver::Celerity class is a Node class. And like Selenium, the Celerity::Browser methods are exposed through 'page.driver.browser'.

The last driver is Culerity. Culerity will "run your application in any Ruby (like MRI 1.8.6) while Celerity runs in JRuby so you can still use gems/plugins that would not work with JRuby." The Capybara::Driver::Culerity class inherits from Celerity and only adds a couple of methods.

capybara/drivers/celerity_driver.rb + culerity_driver.rb
# celerity_driver.rb
class Capybara::Driver::Celerity < Capybara::Driver::Base
  attr_reader :app, :rack_server, :options

  def browser
    ...
      require 'celerity'
      @_browser = ::Celerity::Browser.new(options)
    ...
  end

  def find(selector)
    browser.elements_by_xpath(selector).map { |node| Node.new(self, node) }
  end
  ### other public instance methods:
  # body, current_url, evaluate_script, execute_script,
  # find, reset!, response_headers, source, status_code, visit, wait?
__________________________________________________

# celerity_driver.rb
class Capybara::Driver::Celerity < Capybara::Driver::Base
  class Node < Capybara::Driver::Node
    ### public instance methods:
    # [], checked?, click, drag_to, find, path, select_option,
    # selected?, set, tag_name, text, trigger, unselect_option,
    # value, visible?
__________________________________________________

# culerity.rb
class Capybara::Driver::Culerity < Capybara::Driver::Celerity
  def self.server ...
  def browser 

    Comments

(Please login to submit comments.)



View All Posts