Simplifying UI Testing with data-testid in RSpec & Capybara

Boosting Test Efficiency with data-testid Attributes and Custom RSpec Matchers

Posted by Tobias L. Maier on June 19, 2023

UI testing plays a crucial role in ensuring that our applications work correctly and consistently. Traditionally, people either matched strings, referenced CSS classes or element IDs. But the landscape of UI testing is shifting, introducing a new and improved approach: data-testid.

This blog post explains how to use Test IDs with RSpec and Capybara, and introduces two new RSpec matchers (have_test_id and have_test_id_and_css) to simplify UI testing.

Why the need for a change?

UI testing plays a crucial role in ensuring that our applications work correctly and consistently.

There are multiple approaches how to test UI elements. Matching strings, matching CSS classes or matching element IDs

When matching strings in UI tests, one could match against hard-coded strings. This is not good, as this creates a very tight coupling between the localization and the test case, and is especially complicated when dealing with multilingual applications. In such case, it is better to match against the I18n.t helper, as it supports multilingual capabilities natively and makes the tests less brittle, as not every change to a string leads to an update of the tests.

Another approach is matching CSS classes. This was comparatively easy, when one was following naming conventions, like “Block Element Modifier” (BEM). The CSS class names were deterministic and did not change often. But today, where Utility-First CSS frameworks, like Tailwind CSS, are popular, this is hardly possible. The CSS classes are set as needed to create a certain design. This means they are again brittle and not easy to be targeted with CSS matchers in tests.

The last approach is matching element IDs. This is a quite stable approach, but requires setting IDs on the relevant HTML elements and, especially, to ensure that they are globally unique. This is quite cumbersome and error-prone. Besides this, element IDs are not used too often in modern web applications.

I, personally, used matching strings using I18n.t helper and matching CSS classes in my tests.

Using the I18n.t helper for component tests (ViewComponent), meant that I had to instantiate other components used within a component, to be able to access its I18n.t helper. This was quite tedious to begin with. However, with the shift to ViewComponents 3.0, this was not possible anymore, as the I18n.t helper of a component is only available, when the view context is available, which is only the case, when Rails starts rendering the component. 🤯

Besides this, matching CSS classes was also not properly possible anymore, as I am transitioning to Tailwind CSS.

These issues sparked the need for a new, more efficient approach to testing UI elements.

When I was looking for a solution, I found the data-testid attribute, which is used in the JavaScript ecosystem, with libraries like Cypress and Testing Library.

I started to adapt the data-testid attribute for testing UI elements in my Ruby on Rails application. This attribute offers a reliable way to select elements in the DOM for testing, irrespective of how their text or other attributes might change. It doesn’t need to be globally unique but rather is scoped within the context of a specific component, isolated from other components.

The name data-testid has been chosen intentionally, aligning with its original introduction in certain JavaScript testing libraries.

The impact of data-testid

Easier Identification of Components for Testing

The data-testid attribute provides a consistent way to identify components for testing, especially in applications with complex, dynamic content. There’s no longer a need to instantiate other components in tests, saving both time and effort.

Conscious Component Markup

With the introduction of data-testid, developers need to add these attributes to elements that need to be selectable in tests consciously. While this requires a shift in mindset, the payoff in the form of simplified testing is significant.

Managing Non-Unique IDs

While data-testid attributes don’t need to be globally unique, developers need to ensure that they are unique within the context of a specific component. This is crucial to avoid false positives in tests.

Using data-testid in RSpec & Capybara

Capybara, out of the box, offers fundamental support for Test IDs, with some matchers providing native compatibility, such as expect(page).to have_link(test_id: 'my-test-id'). This feature allows Capybara to understand and use data-testid attributes when testing your UI.

To utilize this functionality, you need to configure Capybara.test_id as follows:

# File: spec/support/capybara.rb

# frozen_string_literal: true

# Enable built-in selectors to understand `data-testid` attribute
#
# find_link('users.checkout') # <a data-testid="users.checkout">Checkout</a>
Capybara.configure do |config|
  config.test_id = 'data-testid'
end

While this configuration provides a basic level of support, it doesn’t offer a dedicated matcher for any element bearing a data-testid attribute. Without such a matcher, developers are often forced to use the have_css matcher: expect(page).to have_css("[data-testid='my-test-id']"). This workaround, while functional, isn’t ideal due to its verbosity and hard-coded attribute name.

To overcome this challenge, I’ve created two new RSpec matchers: have_test_id and have_test_id_and_css, which simplifies the process. (see code below)

These new matchers function as follows:

  • have_test_id: expect(page).to have_test_id('my-id') — Checks if a certain test ID exists within the page.
  • have_test_id for a specific element: expect(page).to have_test_id('my-id', on_element: 'footer') — Checks if a footer element with the specified test ID attribute exists within the page.
  • have_test_id_and_css: expect(page).to have_test_id_and_css("favourite-button-#{publication.id}", "[data-method='delete']", on_element: 'a') — This checks for a link element with the specified test ID and also verifies if another attribute (data-method in this case) has been set correctly.

By leveraging these matchers, you can streamline your testing and code readability when using data-testid.

Wrapping Up

I believe that the introduction of data-testid and these new RSpec matchers will make UI testing in RSpec much more straightforward, readable, and maintainable. Embracing this shift not only aids in writing efficient tests, but also in creating quality, reliable software.

I am happy about learning how use use data-testid or similar Test ID approaches in your tests. Please leave a comment on Mastodon or X/Twitter.

The custom matchers have_test_id and have_test_id_and_css

See the GitHub Gist “Custom RSpec/Capybara matchers for data-testid” for the latest version.

# frozen_string_literal: true

# File: spec/support/matchers/have_test_id.rb
module Matchers
  # @!method have_test_id(test_id, on_element: '*')
  # Matcher for testing the presence of an element with a specific test id on a specific HTML element.
  # If on_element is not provided, the matcher will look for the test_id on any kind of element.
  # @param [String] test_id The test id of the element you're looking for.
  # @option on_element [String] The HTML element that should have the test_id.
  # @return [Boolean] Whether or not the element with the test_id exists on the page.
  RSpec::Matchers.define :have_test_id do |test_id, on_element: '*'|
    match do |page|
      @element_exists = page.has_css?("#{on_element}[#{Capybara.test_id}='#{test_id}']", match: :first)
    end

    match_when_negated do |page|
      @element_does_not_exist = page.has_no_css?("#{on_element}[#{Capybara.test_id}='#{test_id}']", match: :first)
    end

    failure_message do |_page|
      "expected to find #{on_element} element with test id '#{test_id}' but there were none."
    end

    failure_message_when_negated do |_page|
      "expected not to find #{on_element} element with test id '#{test_id}' but it did."
    end
  end
end
# frozen_string_literal: true

# File: spec/support/matchers/have_test_id_and_css.rb
module Matchers
  # @!method have_test_id_and_css(test_id, css, on_element: '*')
  # Matcher for testing the presence of an element with a specific test id and specific CSS on a specific HTML element.
  # If on_element is not provided, the matcher will look for the test_id on any kind of element.
  # @param [String] test_id The test id of the element you're looking for.
  # @param [String] css The CSS that the element with the test_id should have.
  # @option on_element [String] The HTML element that should have the test_id.
  # @return [Boolean] Whether or not the element with the test_id and CSS exists on the page.
  RSpec::Matchers.define :have_test_id_and_css do |test_id, css, on_element: '*'|
    match do |page|
      test_id_css = "#{on_element}[#{Capybara.test_id}='#{test_id}']"
      @base_element_exists = page.has_css?(test_id_css, match: :first)
      @element_exists = @base_element_exists && page.has_css?("#{test_id_css}#{css}", match: :first)
    end

    match_when_negated do |page|
      test_id_css = "#{on_element}[#{Capybara.test_id}='#{test_id}']"
      @base_element_exists = page.has_css?(test_id_css, match: :first)
      return true unless @base_element_exists

      @element_exists = page.has_css?("#{test_id_css}#{css}", match: :first)
      !@element_exists
    end

    failure_message do |_page|
      if !@base_element_exists
        "expected to find #{on_element} element with test id '#{test_id}' but there were none."
      elsif !@element_exists
        "expected #{on_element} element with test id '#{test_id}' to have css '#{css}', but it did not."
      end
    end

    failure_message_when_negated do |_page|
      if @base_element_exists
        "expected not to find #{on_element} element with test id '#{test_id}' but it did."
      elsif @element_exists
        "expected #{on_element} element with test id '#{test_id}' not to have css '#{css}', but it did."
      end
    end
  end
end