Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Ambitious Capybara

Ambitious Capybara

Abstract:
Capybara has allowed us to build complex and ambitious applications with the confidence that everything comes together in the user experience we're targeting. As the capabilities of the web have grown, interactions and behavior in our applications have become more complex and harder to test. Our tests become coupled to CSS selectors, fail intermittently, take longer and our confidence dwindles. In this talk, I'll go over best practices for working with a large Capybara test suite and dig into APIs and options we can use to handle complex apps such as a chat app not written in Ruby.

Given at RailsConf 2015. Slides are edited down from the version used for the presentation (i.e. adding captions and combining slides).

Eduardo Gutierrez

April 22, 2015
Tweet

Other Decks in Programming

Transcript

  1. Little Known Facts • Capybaras (Hydrochoerus hydrochaeris) originate from South

    America • Pronunciation of the name is under heavy debate: capybara vs capybeara
  2. Advanced Facts • Capybaras are semi-aquatic • They can roam

    over areas spanning 25 acres • Very friendly animals, many people have them as pets
  3. Balancing Act Maintainability VS Readability • Some may argue these

    are very similar • Imagine a large DSL that is very readable but potentially hard to maintain (i.e. Rails’ routing DSL) • On the other hand, imagine well factored code that encompasses a complex problem, but still requires a stiff learning curve
  4. And for capybara? Readability • Written as stories a user

    or developer new to the project could understand Maintainability • Growable / refactor-able to introduce new patterns and features Performance • Fast feedback, whether on CI on a developer machine or iterating on a feature
  5. Readability • Avoid raw selectors in feature specs as much

    as possible, especially CSS classes • Leverage capybara DSL as much as possible by using semantic markup • Is the user story described by your feature actually readable as a user story
  6. Readability Let’s refactor a feature test scenario "sign in the

    user" do create(:user, email: "[email protected]") visit sign_in_page fill_in "user_email", with: "[email protected]" fill_in "user_password", with: "password" click_button "Sign In” expect(current_path).to eq profile_path end
  7. Readability scenario "sign in the user" do create(:user, email: "[email protected]")

    visit sign_in_page fill_in "user_email", with: "[email protected]" fill_in "user_password", with: "password" click_button "Sign In” expect(current_path).to eq profile_path end These tests are using DOM IDs to fill in inputs
  8. Readability = simple_form_for :user, url: :sign_in do |f| = f.input

    :email, label: false, placeholder: "email..." = f.input :password, label: false, placeholder: "password..." = f.button :submit, value: "Sign In” Template removes labels in favor of the placeholders
  9. Readability = simple_form_for :user, url: :sign_in do |f| = f.input

    :email, label_html: { class: "hide" }, placeholder: "email..." = f.input :password, label_html: { class: "hide" }, placeholder: "password..." = f.button :submit, value: "Sign In” Can use a utility class to hide them instead
  10. Readability scenario "sign in the user" do create(:user, email: "[email protected]")

    visit sign_in_page fill_in "Email", with: "[email protected]" fill_in "Password", with: "password" click_button "Sign In" expect(current_path).to eq profile_path end Tests now are now more readable
  11. Readability Randomized with seed 36225 . Finished in 3.26 seconds

    (files took 3.43 seconds to load) 1 example, 0 failures Tests still pass
  12. Readability scenario "sign in the user", :js do create(:user, email:

    "[email protected]") visit sign_in_page fill_in "Email", with: "[email protected]" fill_in "Password", with: "password" click_button "Sign In" expect(current_path).to eq profile_path end Test will fail when using `js: true` because Capybara ignores hidden elements in these tests
  13. Readability Randomized with seed 3941 F Failures: 1) Login page

    can sign in the user Failure/Error: fill_in Capybara::ElementNotFound: Unable to find field "Email" # ./spec/features/sign_in_spec:6:in `block (2 levels) in <top (required)>' Finished in 3.72 seconds (files took 3.24 seconds to load) 1 example, 1 failure
  14. Readability = simple_form_for :user, url: :sign_in do |f| = f.input

    :email, label_html: { class: "accessible-hide" }, placeholder: "email..." = f.input :password, label_html: { class: "accessible-hide" }, placeholder: "password..." = f.button :submit, value: "Sign In” Use an accessible method of hiding elements
  15. Readability .accessible-hide { position: absolute !important; height: 1px; width: 1px;

    overflow: hidden; clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ clip: rect(1px, 1px, 1px, 1px); } This will be visible to screen readers and capybara http://snook.ca/archives/html_and_css/hiding-content-for-accessibility
  16. Readability Randomized with seed 7651 . Finished in 2.75 seconds

    (files took 3.28 seconds to load) 1 example, 0 failures
  17. Readability Let’s work on another test: scenario "search users by

    birthdate", :js do create( :user, name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path find(".open-datepicker").click # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_css ".search-result:contains('John')" end
  18. Readability scenario "search users by birthdate", :js do create( :user,

    name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path find(".open-datepicker").click # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_css ".search-result:contains('John')" end ASIDE: We’ll fill this in later
  19. Readability .input %label{ for: "birthday" } Birthday of Contact %input{

    type: :hidden, id: "birthday" } %input{ type: :text, disabled: "" } %button.hide{ type: :button } %button{ type: :button } Simplified datepicker markup
  20. Readability scenario "search users by birthdate", :js do create( :user,

    name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path find(".open-datepicker").click # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_css ".search-result:contains('John')" end Using hard-coded CSS / Sizzle selectors
  21. Readability scenario "search users by birthdate", :js do create( :user,

    name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path # MAYBE ? Unfortunately no... click_button ".open-datepicker" # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_css ".search-result:contains('John')" end
  22. Readability # XPath::HTML.button(locator) # Trimmed and reformatted def button(locator) locator

    = locator.to_s button = descendant(:input)[ attr(:type).one_of('submit', 'reset', 'image', 'button') ] [ attr(:id).equals(locator) | attr(:value).is(locator) | attr(:title).is(locator) ] # ... end `title` is a valid attribute to lookup buttons
  23. Readability .input %label{ for: "birthday" } Birthday of Contact %input{

    type: :hidden, id: "birthday" } %input{ type: :text, disabled: "" } %button.hide{ title: "Clear Input", type: :button } %button{ title: "Open Calendar", type: :button }
  24. Readability scenario "search users by birthdate", :js do create( :user,

    name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path click_button "Open Calendar" # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_css ".search-result:contains('John')" end
  25. Readability scenario "search users by birthdate", :js do create( :user,

    name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path click_button "Open Calendar" # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_css ".search-result", text: "John" end capybara queries can use `:text` option instead
  26. Readability %ul - @results.each do |result| %li.search-result = result.name What

    do we have to hook into here besides tag names or CSS classes?
  27. Readability How about attributes? According to the HTML5 spec: •

    Custom data attributes are intended to store custom data private to the page or application, for which there are no more appropriate attributes or elements. data-
  28. Readability Lets add our own custom selectors for elements: Capybara.add_selector(:models_element)

    do xpath { |name| XPath.css("[data-collection='#{name}']") } end Capybara.add_selector(:model_element) do xpath { |name| XPath.css("[data-resource='#{name}']") } end
  29. Readability And define helpers to make them easier to use:

    module ResourceDomElements def model_element(model_name, options = {}) find(:model_element, model_name, options) end def have_model_element(model_name, options) have_selector :model_element, model_name, options end end
  30. Readability And metaprogram even more painless helpers module ResourceDomElements RESOURCES

    = %w( search_result ) RESOURCES.each do |model_name| class_eval <<-METHODS, __FILE__, __LINE__ + 1 def #{model_name}_element(options = {}) model_element('#{model_name}', options) end def have_#{model_name}_element(options = {}) have_model_element('#{model_name}', options) end METHODS end end
  31. Readability scenario "search users by birthdate", :js do create( :user,

    name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path click_button "Open Calendar" # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_search_result_element text: "John" end
  32. Filling in the Datepicker scenario "search users by birthdate", :js

    do create( :user, name: "John", birthdate: Time.zone.local(1985, 10, 30) ) visit search_path click_button "Open Calendar" # fill in datepicker with Wed, Oct 30, 1985 click_button "Search" expect(page).to have_search_result_element text: "John" end
  33. Filling in the Datepicker scenario "search users by birthdate", :js

    do birthdate = Time.zone.local(1985, 10, 30) create(:user, name: "John", birthdate: birthdate) visit search_path click_button "Open Calendar" within "#ui-datepicker-div" do find( 'select.ui-datepicker-year option', text: birthdate.year.to_s ).select_option find( 'select.ui-datepicker-month option', text: birthdate.strftime('%b') ).select_option find( 'a.ui-state-default:not(.ui-priority-secondary)', text: birthdate.day.to_s, match: :first ).click end click_button "Search" expect(page).to have_search_result_element text: "John" end
  34. Maintainability scenario "search users by birthdate", :js do birthdate =

    Time.zone.local(1985, 10, 30) create(:user, name: "John", birthdate: birthdate) visit search_path click_button "Open Calendar" within "#ui-datepicker-div" do find( 'select.ui-datepicker-year option', text: birthdate.year.to_s ).select_option find( 'select.ui-datepicker-month option', text: birthdate.strftime('%b') ).select_option find( 'a.ui-state-default:not(.ui-priority-secondary)', text: birthdate.day.to_s, match: :first ).click end click_button "Search" expect(page).to have_search_result_element text: "John" end All in one test, likely to be used elsewhere
  35. Maintainability scenario "search users by birthdate", :js do birthdate =

    Time.zone.local(1985, 10, 30) create(:user, name: "John", birthdate: birthdate) visit search_path choose_date birthdate, from: "Birthday of Contact" click_button "Search" expect(page).to have_search_result_element text: "John" end
  36. Maintainability module DatepickerSteps def choose_date(date, options) within "[data-datepicker-input]", text: options[:from]

    do click_button "Open Calendar" end within "#ui-datepicker-div" do find( 'select.ui-datepicker-year option', text: date.year.to_s ).select_option find( 'select.ui-datepicker-month option', text: date.strftime('%b') ).select_option find( 'a.ui-state-default:not(.ui-priority-secondary)', text: date.day.to_s, match: :first ).click end end end Extract another steps module
  37. Maintainability scenario "search users by birthdate", :js do birthdate =

    Time.zone.local(1985, 10, 30) create(:user, name: "John", birthdate: birthdate) visit search_path choose_date birthdate, from: "Birthday of Contact" click_button "Search" expect(page).to have_search_result_element text: “John" expect(page).to have_datepicker_input( "Birthday of Contact", with: birthdate ) end
  38. Maintainability module DatepickerSteps def have_datepicker_input(label_text, options = {}) with =

    options[:with].strftime("%a, %B %d, %Y") have_field label_text, with: value, disabled: true end end
  39. Maintainability module DatepickerSteps def have_datepicker_input(label_text, options = {}) with =

    options[:with].strftime("%a, %B %d, %Y") have_field label_text, with: value, disabled: true end end
  40. Capybara::Selector Capybara.add_selector(:field) do xpath { |locator| XPath::HTML.field(locator) } filter(:checked, boolean:

    true) { |node, value| not(value ^ node.checked?) } filter(:unchecked, boolean: true) { |node, value| (value ^ node.checked?) } filter(:disabled, default: false, boolean: true) { |node, value| not(value ^ node.disabled?) } filter(:with) { |node, with| node.value == with.to_s } filter(:type) do |node, type| if ['textarea', 'select'].include?(type) node.tag_name == type else node[:type] == type end end describe do |options| desc, states = "", [] desc << " of type #{options[:type].inspect}" if options[:type] desc << " with value #{options[:with].to_s.inspect}" if options.has_key?(:with) states << 'checked' if options[:checked] || (options.has_key?(:unchecked) && ! options[:unchecked]) states << 'not checked' if options[:unchecked] || (options.has_key?(:checked) && ! options[:checked]) states << 'disabled' if options[:disabled] desc << " that is #{states.join(' and ')}" unless states.empty? desc end end https://github.com/jnicklas/capybara/blob/master/lib/capybara/selector.rb#L114-L137
  41. Performance within helper • Use to scope multiple assertions in

    the same part of the DOM • Avoids searching through the same parts of the DOM multiple times within
  42. Performance - scenario "add new address from the addresses listing",

    :js do visit addresses_page expect(page).to have_address_element count: 30 click_button "Add Address" fill_in "Street", with: "77 Mass Ave" fill_in "City", with: "Cambridge" select "MA", from: "State" fill_in "Postal Code", with: "02139" select "United States", from: "Country" click_button "Add Address" expect(page).to have_address_element text: "77 Mass Ave" end within
  43. Performance - scenario "add new address from the addresses listing",

    :js do visit addresses_page expect(page).to have_address_element count: 30 click_button "Add Address" within modal_dialogue(text: "New Address") do fill_in "Street", with: "77 Mass Ave" fill_in "City", with: "Cambridge" select "MA", from: "State" fill_in "Postal Code", with: "02139" select "United States", from: "Country" click_button "Add Address" end expect(page).to have_address_element text: "77 Mass Ave" end within
  44. Be wary of elements not found in current scope within

    modal_dialogue(text: "New Address") do fill_in "Street", with: "77 Mass Ave" fill_in "City", with: "Cambridge" select "MA", from: "State" fill_in "Postal Code", with: "02139" select "United States", from: "Country" choose_date 2.months.from_now, from: "Expires At" # => raises `Capybara::ElementNotFound` click_button "Add Address" end Performance - within date picker does not exist in modal_dialogue
  45. Performance Write features as QA scripts • Avoid single test

    for each happy and sad path • Setup / teardown of each test can be expensive • Most useful on pages with a significant amount of markup (~1200+)
  46. Performance - QA Scripts def language_fluency_form find(".new_language_ability") end scenario "allows

    viewing, adding, editing and removing them" do spanish = create(:language, name: "Spanish") create(:language, name: "French") applicant = create(:applicant) create( :language_ability, applicant: applicant, language: spanish, ability: 3 ) sign_in_applicant applicant visit language_abilities_path expect(current_path).to eq language_abilities_path within language_ability_element(text: "Spanish") do expect(page).to have_content "3" click_button "Edit" expect(page).to have_select "Change Fluency", selected: "3" expect(page).to have_button "Save" click_button "Cancel" expect(page).not_to have_select "Change Fluency" expect(page).to have_button "Edit" expect(page).to have_button "Delete" end within language_fluency_form do expect(page).to have_css( "option[disabled]", text: "Spanish", visible: false ) click_button "Add Language" expect(page).to have_css( ".input-error-message", count: 2, text: I18n.t("errors.messages.blank") ) chosen_select "French", from: "Language" choose "4" click_button "Add Language" expect(page).to have_chosen_select "Language", selected: "" expect(page).not_to have_field "Fluency" expect(page).to have_css( "option[disabled]", text: "French", visible: false ) end within language_ability_element(text: "French") do expect(page).to have_content "4" click_button "Edit" select "5", from: "Change Fluency" click_button "Save" expect(page).not_to have_select "Change Fluency" expect(page).to have_content "5" end within language_ability_element(text: "Spanish") do click_button "Delete" A single integration test for a page of the app
  47. Performance - QA Scripts scenario "allows viewing, adding, editing and

    removing them" do spanish = create(:language, name: "Spanish") create(:language, name: "French") applicant = create(:applicant) create( :language_ability, applicant: applicant, language: spanish, ability: 3 ) sign_in_applicant applicant visit language_abilities_path expect(current_path).to eq language_abilities_path Create seed records
  48. Performance - QA Scripts scenario "allows viewing, adding, editing and

    removing them" do spanish = create(:language, name: "Spanish") create(:language, name: "French") applicant = create(:applicant) create( :language_ability, applicant: applicant, language: spanish, ability: 3 ) sign_in_applicant applicant visit language_abilities_path expect(current_path).to eq language_abilities_path Create seed records Visit page under test
  49. Performance - QA Scripts within language_ability_element(text: "Spanish") do expect(page).to have_content

    "3" click_button "Edit" expect(page).to have_select "Change Fluency", selected: "3" expect(page).to have_button "Save" click_button "Cancel" expect(page).not_to have_select "Change Fluency" expect(page).to have_button "Edit" expect(page).to have_button "Delete" end Check editing existing record
  50. Performance - QA Scripts within language_ability_element(text: "Spanish") do expect(page).to have_content

    "3" click_button "Edit" expect(page).to have_select "Change Fluency", selected: "3" expect(page).to have_button "Save" click_button "Cancel" expect(page).not_to have_select "Change Fluency" expect(page).to have_button "Edit" expect(page).to have_button "Delete" end Ensure cancel works Check editing existing record
  51. Performance - QA Scripts within language_fluency_form do expect(page).to have_css( "option[disabled]",

    text: "Spanish", visible: false ) click_button "Add Language" expect(page).to have_css( ".input-error-message", count: 2, text: I18n.t("errors.messages.blank") ) chosen_select "French", from: "Language" choose "4" click_button "Add Language" expect(page).to have_chosen_select "Language", selected: "" expect(page).not_to have_field "Fluency" expect(page).to have_css( "option[disabled]", text: "French", visible: false ) end Ensure selected languages are disabled Make sure client-side validations run
  52. Performance - QA Scripts within language_fluency_form do expect(page).to have_css( "option[disabled]",

    text: "Spanish", visible: false ) click_button "Add Language" expect(page).to have_css( ".input-error-message", count: 2, text: I18n.t("errors.messages.blank") ) chosen_select "French", from: "Language" choose "4" click_button "Add Language" expect(page).to have_chosen_select "Language", selected: "" expect(page).not_to have_field "Fluency" expect(page).to have_css( "option[disabled]", text: "French", visible: false ) end Ensure selected languages are disabled Make sure client-side validations run Check form is updated correctly
  53. Advanced Capybara::Window enables working between multiple windows within a single

    spec • SOA applications spread out across different domains • Client-side applications built in two repositories
  54. Advanced scenario "ensure removed inventory is removed from the API",

    :js do CMS_URL = "http://localhost:3000" API_URL = "http://localhost:3001" cms_window = window_opened_by do visit CMS_URL create_product!(name: "Foo") end store_window = window_opened_by do visit API_URL expect(page).to have_product_element text: "Foo" end within_window cms_window do within product_element(text: "Foo") do click_button "Out of Stock" end end within_window store_window do visit API_URL expect(page).not_to have_product_element text: "Foo" end end