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
# 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.
# 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
# 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.)
# 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.
# 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').
# 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.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.
# 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.
# 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
(Please login to submit comments.)
Comments