Skip to content

Instantly share code, notes, and snippets.

@krzykamil
Created February 27, 2024 11:18
Show Gist options
  • Save krzykamil/d0ccd77ae553513f3618f2c7ce6f6f9a to your computer and use it in GitHub Desktop.
Save krzykamil/d0ccd77ae553513f3618f2c7ce6f6f9a to your computer and use it in GitHub Desktop.
Capybara

Capybara

Capybara is good when it's good, and it's bad when it's bad. When you don't know how to use it, it can be tragic. When you use it for the wrong job, it is a pain.

Here us a quick, and absolutely subjective guide to using it correctly, which mostly means adapting it to the needs of your project, handling it limitations, and connecting it to RSpec.

Matchers

Main problem with asserting in RSpec and Capybara for me is that there is too many options. However RSpec makes it relatively easy to build your own. So instead of always googling and checking documentation while making sure you are looking at the right version of it, you can adapt those matchers to the needs of your projects.

This can mean making aliases to simplify the naming and always use the same, limited number of matchers, or creating your own from scratch.

I will go over a few examples.

module Support::CapybaraMatchers
  extend RSpec::Matchers::DSL

  class HaveFieldsWithValuesMatcher
    def initialize(prefix, fields)
      @prefix = prefix
      @fields = fields
    end

    def matches?(page)
      @fields.each do |field, expected_value|
        @field = "#{@prefix}#{field}"
        @expected_value = expected_value
        @value = nil

        begin
          @value = page.find_field(@field).value
          return false unless @expected_value == @value
        rescue Capybara::ElementNotFound
          return false
        end
      end
    end

    def failure_message
      if @value
        "Excepted field #{@field} to have value '#{@expected_value}' but it had " \
          "'#{@value}'"
      else
        "Excepted field #{@field} to have value '#{@expected_value}' but the " \
          'field was not found'
      end
    end

    def failure_message_when_negated
      if @value
        "Excepted field #{@field} to not have value #{@expected_value}"
      else
        "Excepted field #{@field} to not have value '#{@expected_value}' but the " \
          'field was not found'
      end
    end
  end

  def have_fields_with_values(prefix, fields)
    HaveFieldsWithValuesMatcher.new(prefix, fields)
  end

  def have_field_with_value(field, value)
    HaveFieldsWithValuesMatcher.new('', field.to_sym => value)
  end

  matcher :have_selected_values do |prefix, fields|
    match do |actual|
      fields.each do |field, value|
        return false unless actual.has_select? "#{prefix}#{field}", selected: value
      end
      true
    end
  end

  matcher :have_hidden_field_with_value do |field, value|
    match do |actual|
      actual.find("##{field}", visible: false).value == value
    end
  end

  matcher :have_hidden_fields_with_values do |prefix, fields|
    match do |actual|
      fields.each do |field, value|
        return false unless actual.find("##{prefix}#{field}", visible: false).value == value
      end
      true
    end
  end
end

In general we can define all matchers in one support file, that groups them together. This way we can include them in RSpec configuration and use them in our tests. Below (after the examples) you also have a simple automatic inclusion in RSpec configuration. As for the example itself, this matcher was created due to often repetition of checking for the field existence and value. It is used in the following way:

  it 'has the correct values' do
    expect(page).to have_fields_with_values('search_', {
      start_date: '2018-01-01', end_date: '2018-01-31'
    })
  end

Without the matcher, the code would look like this:

  it 'has the correct values' do
    expect(page).to have_field('search_start_date', with: '2018-01-01')
    expect(page).to have_field('search_end_date', with: '2018-01-31')
  end

So you have to repeat the prefix for each field of the given object/form (search) as many times as there are fields. This is not only more readable, but also more maintainable, as you can change the prefix in one place, and not in every test, so if the field changes, the change is also in one place.

If more attributes come, you do not need to add another line, but just another key-value pair to the hash. This was very useful in the project that has multiple big forms, less expects and simpler code structure thanks to simple matcher.

There are also additions in the form of hidden fields matcher, select matcher which is always a pain. Those two cases are often a big of a problem in capybara since it is easy to forget how to handle them, and there are a few ways to do it. Those matcher give us a standardized way to do it, and also a way to make it more readable.

We get a controllable and clear failure messages, that we adapt to the way we want to debug the spec.

Another example:

  class HaveLabelsWithValuesMatcher
    def initialize(instance, fields)
      @instance = instance
      @fields = Array(fields)
    end

    def matches?(page)
      @fields.each do |field|
        @label = @instance.class.human_attribute_name(field)
        @value = @instance.send(field)
        return false unless page.has_content? "#{@label}: #{@value}"
      end
    end

    def failure_message
      "Could not find label '#{@label}' with content '#{@value}'"
    end

    def failure_message_when_negated
      "Expected to not find label '#{@label}' with content '#{@value}'"
    end
  end

  def have_labels_with_values(instance, fields)
    HaveLabelsWithValuesMatcher.new(instance, fields)
  end

  class HaveLabelsMatcher
    def initialize(klass, fields)
      @klass = klass
      @fields = Array(fields)
    end

    def matches?(page)
      @fields.each do |field|
        @label = @klass.human_attribute_name(field)
        return false unless page.has_content? @label
      end
    end

    def failure_message
      "Could not find label '#{@label}'"
    end
  end

  class HaveNoLabelsMatcher
    def initialize(klass, fields)
      @klass = klass
      @fields = Array(fields)
    end

    def matches?(page)
      @fields.each do |field|
        @label = @klass.human_attribute_name(field)
        return false if page.has_content? @label
      end
    end

    def failure_message
      "Expected to not find label '#{@label}'"
    end
  end

  def have_labels(klass, fields)
    HaveLabelsMatcher.new(klass, fields)
  end

  def have_no_labels(klass, fields)
    HaveNoLabelsMatcher.new(klass, fields)
  end

This is more of a syntax sugar, for a test that was often repeated in the project. It is a simple way to check if the labels are present on the page, and if they have the correct values. It is used in the following way:

  it 'has the correct labels' do
    expect(page).to have_labels_with_values(@user, %i[name email])
end

Once again, instead of having an expect statement for every label, we have one line that checks all of them. This is a simple example, but it can be very useful in a project with a lot of forms and a lot of fields.

Of course not always you need to check for labels in a spec, but when you do, it is a good way to do it.

Another example:

  matcher :have_content_order do
    match do |actual|
      document = actual.try(:document)
      if document.respond_to? :synchronize
        document.synchronize { try_match! }
      else
        try_match!
      end
      true
    rescue Capybara::ElementNotFound
      false
    end

    def failure_message
      "Expected to find #{expected_as_array.map(&:inspect).join(', ')} in order, but their indexes were #{@pos.join(', ')}"
    end

    private

    def try_match!
      @pos = expected_as_array.map { |text| actual.text.index(text) }
      raise Capybara::ElementNotFound unless @pos.all? && (@pos == @pos.sort)
    end
  end

  matcher :have_meta_tag do |property, content, match: :exact|
    match do |actual|
      if content.empty?
        actual.has_css?("meta[property=\"#{property}\"]", visible: false)
      elsif match == :contains
        actual.has_css?("meta[property=\"#{property}\"][content*=\"#{content}\"]", visible: false)
      else
        actual.has_css?("meta[property=\"#{property}\"][content=\"#{content}\"]", visible: false)
      end
    end
  end

The have_content_order example is very specific to a project, but also a good example of creating a more understandable matcher. It is used in the following way:

  it 'has the correct order' do
    expect(page).to have_content_order(*company_info.map(&:name))
end

Checking order in which things are displayed is an often case in specs, for query of some index pages. There is no one standardized way of doing it, and it is usually a very "raw" test, that maps and orders in the expectation. This is often confusing and the setup also has its own problems and ordering, sorting. This matcher uses a more hidden DSL and less understandable code underneath, to give us a very clear matcher that "just works".

The other example is also a good example of hiding a very unreadable matcher that is often useful. Those deeply nested tags searches are a pain in capybara specs. Having a standardized way of testing it, also enforces a standardized way of writing the HTML itself, along with its tags. If you write the test first, and then adapt the code to meet its demands, your Capybara tests will not only be easier to write, but will also help you create a more concise code

And here is the config to automatically include those matchers in RSpec (you also need the support folder to be required in the rspec/rails helper):

RSpec.configure do |rspec|
    rspec.include self, type: :feature
    rspec.include self, type: :component
end

Helpers

Alongside matchers, we can also create helpers that will make it easier to create concise, consistent, readable specs, instead of the usual bloated mess that capybara specs can be:

module Support
  module CapybaraHelpers
  #
  # Check visibility of page elements
  #

    def visible?(id)
      page.has_selector?(id, visible: true)
    end

    def invisible?(id)
      page.has_selector?(id, visible: :hidden)
    end

    def check_visibility_becomes(visible: nil, invisible: nil)
      visible = Array.wrap(visible)
      invisible = Array.wrap(invisible)

      visible.each do |item|
        expect(invisible?(item)).to eq(true), "#{item} was not already invisible"
      end

      invisible.each do |item|
        expect(visible?(item)).to eq(true), "#{item} was not already visible"
      end

      yield

      visible.each do |item|
        check_visibility(item, true)
      end

      invisible.each do |item|
        check_visibility(item, false)
      end
    end

    def check_visibility(selector, visibility)
      if page.document.respond_to?(:synchronize)
        page.document.synchronize do
          check_visibility_raw(selector, visibility)
        end
      else
        check_visibility_raw(selector, visibility)
      end
    end

    def check_visibility_raw(selector, visibility)
      find(selector, visible: false).synchronize do
        return if visible?(selector) == visibility
        raise Capybara::ElementNotFound, "#{selector} is not #{:in unless visibility}visible"
      end
    end
    private :check_visibility_raw

    def attach_file(locator, path, **options)
      super locator, File.absolute_path(path), **options
    end

    def fill_in_form_text(prefix, attributes)
      attributes.each do |name, value|
        fill_in "#{prefix}_#{name}", with: value
      end
    end

    def select_values(prefix, fields)
      fields.each do |field, value|
        select value, from: "#{prefix}#{field}", selectize: false
      end
    end

    def selectize_has_option?(key, value)
      selectize_div = page.find(:xpath, %(//input[@id="#{key}"]/../div[1]), visible: false)
      begin
        selectize_div.find(:xpath, %(.//div[@data-value="#{value}"]), visible: false)
        true
      rescue Capybara::ElementNotFound
        false
      end
    end

    def selectize_has_no_option?(key, value)
      selectize_div = page.find(:xpath, %(//input[@id="#{key}"]/../div[1]), visible: false)
      selectize_div.has_no_xpath?(%(.//div[@data-value="#{value}"]), visible: false)
    end

    def get_selectize(field_id)
      # This might need additional code for select boxes
      # field = page.find_field(key, visible: false)
      page.evaluate_script(%{
        $('##{field_id}').selectize()[0].selectize.getValue()
       })
    end
  
    def has_image?(src)
      has_xpath?("//img[contains(@src,\"#{src}\")]")
    end

    def has_link_with_text?(text, href:)
      has_xpath? "//a[@href='#{href}'][contains(text(), #{text}']"
    end
  end
  
  module HeadingSteps
  extend RSpec::Matchers::DSL
    def have_heading(text)
      have_selector('h2', text: text)
    end

    def have_subheading(text)
      have_selector('h3', text: text)
    end
  end
end

So those are basically wrappers for some capybara dsl steps that tests usually have to take to either create a context for the assertions, or a page state or scope in which assertions need to happen. In big feature specs, this can be cumbersome, bloated and hard to read and understand what is happening visually on the page. Specially if there are many ways of achieving that page state. An example of this, are helpers like fill_in_form_text or attach_file. Many ways to achieve that, so we create one that is as concise and clear about its intentions as possible.

We can also create blocks that take in actions and do an test on their result:

check_visibility_becoms(invisible: '#loading', visible: '#content') do
  choose "option"
end

We wrap an action in block, and then we check if the visibility of the elements changes as expected. This is a very common pattern in feature specs, and it is often repeated, so it is a good idea to create a helper for it.

Other wise, you have to get many lines of code, here you can pass array and be done in 3 lines of code if the option element controls many other elements visibility (like changing options on forms that shows/hides many other field). Very useful in big views, specially given how common pattern is hiding/showing something on click, including doing it to many things at once.

There are also wrappers for select fields, which are a common issue in capybara. This adapts wrappers and helpers to work with the select types used in this particular project (it used selectize). Dealing with those fields is often a problem in Capybara, since most projects use some sort of select plugin and testing it becomes unclear with the default option capybara gives you. Those helpers aim to use the complicated, ugly xpaths and method calls underneath a clean DSL.

select_values('search_', { start_date: '2018-01-01', end_date: '2018-01-31' })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment