citizensadvice / capybara_accessible_selectors

Custom selectors for Capybara
ISC License
77 stars 8 forks source link

Capybara accessible selectors

A set of Capybara selectors that allow you to find common UI elements by labels and using screen-reader compatible mark-up.

Cheat sheet

Philosophy

All feature tests should interact with the browser in the same way a screen-reader user would. This both tests the feature, and ensures the application is accessible.

To be accessible to a screen-reader, a page should be built from the native html elements with the semantics and behaviour required for each feature. For example if the page contains a button it should use <button> element rather than adding a onClick handler to a <span>.

Where a feature does not exist in HTML, such as tabs, then ARIA roles and states can be used to convey the meaning to a screen-reader.

For a better overview see Using aria.

As a result all tests should be built from the visible labels on the page, and the semantic meaning of elements, and ARIA roles and attribute.

CSS and XPATH selectors based on classes, ids and nesting elements with no semantic meaning, should not be used.

This gem contains a set of selectors and filters for common UI elements and element states that are not already included in Capybara. These selectors follow the guidelines in ARIA Authoring Practices.

Examples:

# Bad selectors
# - fragile and does not check the control is correctly labelled

page.find(:css, "#widget > div > .field").set("Bob")

fill_in "field_name_1", with: "Bob"

page.find(:css, "#tab_1").click

within(:css, "#tabs > div > div.panel:first-child") do
  expect(page).to have_text "Client name Bob"
end

# Good selectors
# - based on how a screen reader would hear and navigate a page

within_fieldset "User details" do
  fill_in "First name", with: "Bob"
end

select_tab "Client details"

expect(page).to have_tab_panel "Client details", text: "Client name Bob"

within_modal "Are you sure?" do
  click_button "OK"
end

Usage

Include in your Gemfile:

group :test do
  # It is recommended you use a tag as the main branch may contain breaking changes
  gem "capybara_accessible_selectors", git: "https://github.com/citizensadvice/capybara_accessible_selectors", tag: "v0.12.0"
end

Documentation

See the Capybara cheatsheet for an overview of built-in Capybara selectors and actions.

Filters

accessible_name [String, Regexp]

Added to all selectors.

Currently only supported for Selenium drivers.

Filters for an element's computed accessible name. A string will do a partial match, unless the exact option is set.

This uses the Selenium driver accessible_name method which uses the accessible name calculated by the browser. Support is reasonably good in modern browsers for common use cases, but browsers do not produce the same results for all cases and some screen-readers will perform their own calculations.

This method must request the accessible name from the driver for each node found by the selector individually. Therefore, using this with a selector that returns a large number of elements will be inefficient.

<button id="id1" aria-labelledby="id1 id2">Delete</button>
<a id="id2" href="https://github.com/citizensadvice/capybara_accessible_selectors/blob/main/files/Documentation.pdf">Documentation.pdf</a>
click_button accessible_name: "Delete Documentation.pdf"

aria [Hash]

Added to all selectors.

Filters for an element that declares ARIA attributes

<button aria-controls="some-state" aria-pressed="true">A pressed button</button>
expect(page).to have_selector :button, "A pressed button", aria: { controls: "some-state", pressed: true }

current [String, Symbol]

Added to: link, link_or_button.

Is the element the current item within a container or set of related elements using aria-current.

For example:

<ul>
  <li>
    <a href="https://github.com/citizensadvice/capybara_accessible_selectors/blob/main/">Home</a>
  </li>
  <li>
    <a href="https://github.com/citizensadvice/capybara_accessible_selectors/blob/main/about-us" aria-current="page">About us</a>
  </li>
</ul>
expect(page).to have_link "Home", current: nil
expect(page).to have_link "About us", current: "page"

Note: The [aria-current] attribute supports both "true" and "false" values. A current: true will match against [aria-current="true"], and a current: false will match against [aria-current="false"]. To match an element without any [aria-current] attribute, pass current: nil.

described_by [String, Regexp]

Added to all selectors.

Is the field described by some text using aria-describedby.

For example:

<label>
  My field
  <input aria-describedby="id1 id2" />
</label>
<span id="id1">My</span>
<span id="id2">description</span>
expect(page).to have_field "My field", described_by: "My description"

fieldset [String, Symbol, Array]

Added to: button, link, link_or_button, field, fillable_field, radio_button, checkbox, select, file_field, combo_box and rich_text.

Filter for controls within a <fieldset> by <legend> text. This can also take an array of fieldsets for multiple nested fieldsets.

For example:

<fieldset>
  <legend>My question</legend>
  <label>
    <input type="radio" name="radios" />
    Answer 1
  </label>
  <label>
    <input type="radio" name="radios" />
    Answer 2
  </label>
</fieldset>
find :radio_button, "Answer 1", fieldset: "My question"
choose "Answer 1", fieldset: "My question"

Also see ↓ Locating fields

required [Boolean]

Added to: button, link, link_or_button, field, fillable_field, radio_button, checkbox, select, file_field, combo_box and rich_text.

Filter for controls with a required or aria-required attribute.

For example:

<label>
  <input requied />
  Text
</label>
find :field, required: true

role [String, Symbol, nil]

Added to all selectors

Filters for an element with a matching calculated role, or with no role if nil is supplied.

The roles "none", "presentation" and "generic" are returned as nil as they are implementation details not exposed to users.

This uses the Selenium driver aria_role method which uses the role calculated by the browser taking into account implicit role mappings. The results for implicit roles are not consistent across all browsers, but are good for more common use cases.

Rack test will currently only use the value of the role attribute and does not return implicit role values.

This method must request the role from the driver for each node found by the selector individually. Therefore, using this with a selector that returns a large number of elements will be inefficient.

<label for="switch-input">A switch input</label>
<input id="switch-input" type="checkbox" role="switch" />
expect(page).to have_field "A switch input", role: "switch"

validation_error [String, Regexp, true, false]

Added to: field, fillable_field, datalist_input, radio_button, checkbox, select, file_field, combo_box and rich_text.

Filters for an element being both invalid, and has a description or label containing the error message.

This differs from the Capybara valid and validation_message filters which only consider the HTML validation API.

To be invalid, the element must

For the error description, this can be contained in the accessible description, or the accessible name.

<label>
  My field
  <input required aria-describedby="error-id" />
</label>
<span id="error-id">This is required</span>
expect(page).to have_field "My field", validation_error: "This is required"

# Just check the field is invalid
expect(page).to have_field "My field", validation_error: true

# Just check the field is not invalid
expect(page).to have_field "My field", validation_error: false

Also see:

Selectors

Locating fields

The following selectors have been extended so you can use an array as the locator to select within a fieldset. The last element of the array is the field label, and the other elements are fieldsets.

Extended selectors: button, link, link_or_button, field, fillable_field, datalist_input, radio_button, checkbox, select, file_field, combo_box, rich_text.

<fieldset>
  <legend>My question</legend>
  <label>
    <input type="radio" name="radios" />
    Answer 1
  </label>
  <label>
    <input type="radio" name="radios" />
    Answer 2
  </label>
</fieldset>
find :radio_button, ["My question", "Answer 1"]
choose ["My question", "Answer 1"]

Also see fieldset filter

alert

Selects an element with the role of alert.

<div role="alert">Important message</div>
expect(page).to have_selector :alert, text: "Successfully saved"
expect(page).to have_alert, text: "Successfully saved"

Also see ↓ Expectation shortcuts

article

Finds an article structural role. The selector will match either an <article> element or an element with role="article".

Also see:

banner

Finds a banner landmark.

Also see:

columnheader

Finds a columnheader cell that's either a <th> element descendant of a <table>, or a [role="columnheader"] element.

Also see:

Example:

<table role="grid">
  <tr>
    <th>A columnheader</th>
  </tr>
</table>

<div role="grid">
  <div role="row">
    <div role="columnheader">A columnheader</div>
  </div>
</div>
expect(page).to have_selector :columnheader, "A columnheader", count: 2

combo_box

Finds a combo box. This will find ARIA 1.0 and ARIA 1.1 combo boxes. A combo box is an input with a popup list of options.

This also finds select based on Twitter typeahead classes, but this behaviour is deprecated and will be removed in a future release.

Locator and options are the same as the field selector with the following additional filters:

Option text is normalised to single white spaces.

Note that the built-in Capybara selector datalist_input will find a native html list attribute based combo-box.

Also see:

contentinfo

Finds a contentinfo landmark.

Also see:

dialog

Finds a dialog.

This checks for either

Also see:

disclosure

Finds a disclosure. This will find both a native disclosure (<details>/<summary>) and an ARIA disclosure.

Note that an ARIA disclosure is typically hidden when closed. Using expanded: false will only find an element where visible: is set to false or :all.

Also see:

disclosure_button

Finds the open and close button associated with a disclosure. This will be a <summary>, a <button> or an element with the role of button.

Also see:

grid

Finds a grid element that declares [role="grid"].

Also see:

Example:

<table role="grid" aria-label="A grid"></table>
<div role="grid" aria-label="A grid"></div>
expect(page).to have_selector :grid, "A table grid", count: 2

gridcell

Finds a gridcell element that's either a <td> descendant of a <table> or declares [role="gridcell"].

Also see:

Example:

<table role="grid">
  <tr>
    <td>A gridcell</td>
  </tr>
</table>

<div role="grid">
  <div role="row">
    <div role="gridcell">A gridcell</div>
  </div>
</div>
expect(page).to have_selector :gridcell, "A gridcell", count: 2

heading

Finds a heading. This can be either an element with the role "heading" or h1 to h6.

Also see:

Example:

<div role="heading">Heading</div>
<h2>Heading</h2>
expect(page).to have_selector :heading, "Heading", count: 2

item and item_type

Finds a microdata item.

Microdata isn't exposed to users, including screen-readers. However this can still be a useful way to check a page has the expected information in the expected place.

Example:

<dl itemscope itemtype="application:person">
  <dt>First name</dt>
  <dd itemprop="first-name">Bob</dd>
  <dt>Last name</dt>
  <dd itemprop="last-name">Hoskins</dd>
</dl>
expect(page).to have_selector :item, "first-name", type: "application:person", text: "Bob"
expect(page).to have_selector :item_type, "application:person"

Also see ↓ Expectation shortcuts

main

Finds a main landmark.

Also see:

menu

Finds a menu.

<div role="menu" aria-label="Actions">
  <button type="button" role="menuitem">Share</li>
  <button type="button" role="menuitem">Save</li>
  <button type="button" role="menuitem">Delete</li>
</div>
expect(page).to have_selector :menu, "Actions"
expect(page).to have_selector :menu, "Actions", expanded: true

menuitem

Finds a menuitem.

<div role="menu" aria-label="Actions">
  <button type="button" role="menuitem">Share</li>
  <button type="button" role="menuitem" aria-disabled="true">Save</li>
  <button type="button" role="menuitem">Delete</li>
</div>
within :menu, "Actions", expanded: true do
  expect(page).to have_selector :menuitem, "Share"
  expect(page).to have_selector :menuitem, "Save", disabled: true
  expect(page).to have_no_selector :menuitem, "Do something else"
end

modal

Finds a modal dialog.

This checks for either

Also see:

navigation

Finds a navigation landmark.

Also see:

row

Finds a row element that's either a <tr> descendant of a <table> or declares [role="row"].

Also see:

Example:

<table role="grid">
  <tr>
    <td>Within a row</td>
  </tr>
</table>

<div role="grid">
  <div role="row">
    <div role="gridcell">Within a row</div>
  </div>
</div>
expect(page).to have_selector :row, "Within a row", count: 2

region

Finds a region landmark.

Also see:

rich_text

Finds a rich text editor.

This should be compatible with most browser based rich text editors. It searches for an element with the contenteditable attribute marked up with the textbox role. It is also compatible with <iframe> based editors such as CKEditor 4 and TinyMCE.

For testing the content of an iframe based editor you need to use within_frame, or you can use within_rich_text.

# non-iframe based editors
expect(page).to have_selector :rich_text, "Label", text: "My content"

# iframe based editors
within_frame find(:rich_text, "Label") do
  expect(page).to have_text "My content"
end

Also see:

section

Finds a section of the site based on the first heading in the section.

A section is html sectioning element: <section>, <article>, <aside>, <footer>, <header>, <main> or <form>.

<section>
  <div>
    <h2>My section</h2>
  </div>
  Some content
</section>
within :section, "My section" do
    expect(page).to have_text "Some content"
end

Also see

tab_panel

Finds a tab panel.

Note that a closed tab panel is not visible. Using open: false will only find an element where visible: is set to false or :all.

Also see

tab_button

Finds the button that opens a tab.

Also see:

Actions

fill_in_rich_text(locator, **options)

Fill in a rich text field with plain text.

fill_in_rich_text "Diary entry", with: "Today I published a gem"

Also see rich_text selector

select_tab(name, &block)

Opens a tab by name.

select_tab "Client details"

Also see tab_panel selector

select_combo_box_option(with, **options)

Fill in a combo box and select an option

select_combo_box_option "Apple", from: "Fruits"

Also see combo_box selector

select_disclosure(name)

Open disclosure if not already open, and return the disclosure.

select_disclosure("Client details")
select_disclosure "Client details" do
  expect(page).to have_text "The Client details contents"
end

Also see disclosure selector

toggle_disclosure(name, expand:)

Toggle a disclosure open or closed, and return the button

toggle_disclosure("Client details")
toggle_disclosure "Client details", expand: true do
  expect(page).to have_text "The Client details contents"
end

Also see disclosure selector

Limiting

within_disclosure(name, **find_options, &block)

Executing the block within a disclosure.

within_disclosure "Client details" do
  expect(page).to have_text "Name: Frank"
end

Also see disclosure selector

within_dialog(name, **find_options, &block)

Execute the block within a dialog

within_dialog "Settings" do
  check "Dark mode"
end

Also see dialog selector

within_modal(name, **find_options, &block)

Execute the block within a modal.

within_modal "Are you sure?" do
  click_button "Confirm"
end

Also see modal selector

within_rich_text(name, **find_options, &block)

Execute within the rich text. If the rich text is iframe based this will execute "within_frame".

within_rich_text "Journal entry" do
  expect(page).to have_text "Today I went to the zoo"
end

Also see rich_text selector

within_section(name, **find_options, &block)

Execute the block within a section.

within_section "Heading" do
  expect(page).to have_text "Section content"
end

Also see section selector

within_tab_panel(name, **find_options, &block)

Executing the block within a tab panel.

within_tab_panel "Client details" do
  expect(page).to have_text "Name: Fred"
end

Also see tab_panel selector

Expectations

have_validation_errors(&block)

Checks if a page has a set of validation errors.

This will compare all the validation errors on a page with an expected set of errors. If the errors do not match it will not pass.

expect(page).to have_validation_errors do
  field "Name", validation_error: "This is required"
  select "Gender", validation_error: "This is required"
  field "Age", validation_error: "Choose a number less than 120"
  radio_group "Radio questions", validation_error: "Select an option"

  # You can use all the field selectors in the block
  # checkbox datalist_input field file_field fillable_field radio_button select combo_box rich_text

  # Additionally a "radio_group" selector will find all radios in a fieldset
  # You can also use "within" and "within_fieldset" methods
end

Also see validation_error filter

have_no_validation_errors

Checks if a page has no invalid fields.

expect(page).to have_no_validation_errors

Also see validation_error filter

Expectation shortcuts

The following expectation shortcuts are also added for both have_selector_ and have_no_selector_:

For example the following two are equivalent:

expect(page).to have_selector :combo_box, "Foo"
expect(page).to have_combo_box, "Foo"

Local development

# install
bundle install

# lint
bundle exec rubocop

# test
# A local install of Chrome is required for the selenium web driver
bundle exec rspec