Make Yourself Accessible: Legacy Rails Application

  • July 22, 2022
  • Nicholas Marshall
  • 17 min read

This is the third in a three-part series on "what accessibility actually looks like when you're implementing it in a Rails app." Part One covered some of the moral, ethical and professional rationale for making a site that adequately serves as many people as possible. Part Two covered implementing accessibility checks in a brand-new application, along with a minimal overview of setting up system specs. If you're new to system testing in general, I'd highly recommend investigating some of the links in that article to get a good grounding in system specs before proceeding below.

That's because Part Three of this series is going to talk about the monster under the bed: what do we do with existing code? What if accessibility hasn't been a priority in our codebase up until now? How do we fix it?

Accessibility in Older Projects, or "Oh God Look at All Those Failures Agh"

If we want to make an existing site accessible, and add in some automated checks as outlined in Part Two, we may suddenly be faced with an ocean of failing specs. Planning and executing an accessibility upgrade without losing your mind is easier with a few tricks and some careful planning. Here's one approach.

Overview of Approach

Our goal is to take an existing Rails app that doesn't meet accessibility standards, and set up a project workflow that allows us to iterate on the problem and work towards strong, stable compliance with accessibility standards.

We're going to do the following:

  1. Create failing specs in any spec that deals with a page that should be accessible.
  2. Document those failures in a spreadsheet, and break the larger set of failures into smaller, more manageable chunks.
  3. Configure RSpec to record and target the files with failures using a custom spec helper or a shared context.
  4. Document any known issues inline using RSpec tags and a custom matcher.

The approach laid out in this post can be applied to any large-scale refactor requiring numerous small changes.

Some Assumptions and Guiding Principles

We're going to make the following assumptions about the current state of our codebase that needs accessibility upgrades:

  • System or page tests have been configured, and decent coverage exists for your application.
  • Accessibility checks are running inline with your system specs, and are able to catch a good number of accessibility issues.
  • You have a clean copy of your current develop or main branch to uses as your starting point. The process outlined here will involve a significant number of smaller fixes, and should not be combined with other work.
  • Crucially, you need to have enough bandwidth and buy-in to schedule the time and resources required to rewrite a lot of HTML. Trying to rewrite the whole front-end to fix accessibility all at once will likely drive your coworkers nuts while failing to make material progress on the goal (which will drive Product and QA nuts). Don't do that (TRUST ME ON THIS ONE).

But it's code, and things can always get a little more complicated. You'll likely encounter pages and behaviors that would need more work than you can justify in order to be fully accessible. We want to avoid scope creep, and we may need to sacrifice perfect, clean accessibility checks in favor of getting everything done in a reasonable timeframe. As a developer, I have a lot of trouble doing this because I always want the problem to be perfectly resolved once it's been identified.

But perfection is the enemy of the way, way, way better, and there's a lot we can do to get older projects on a much better path.

So let's also set out these ground rules for this project:

  1. We're not trying to fix everything immediately.
  2. We want to use smaller, cleaner PRs wherever possible in order to make the task manageable.
  3. We want to keep track of the current state of accessibility from within the codebase, including issues that we can't fix.
  4. We want to fix as many accessibility issues as we can, given these constraints.

For the purposes of this post, we can also assume that our spec from Part Two does not currently check for accessibility issues, and looks like this:

it "can visit the user creation page from the users index", :system do
  visit users_path

  click("Create New User")

  expect(page).to have_current_path(new_user_path)
end

The page object represents the state of the current page loaded by Capybara after the indicated behavior has been performed.

Great. We can get started. What's next?

Make Everything Fail 😀

The first step here is to identify your failures. It doesn't really matter where you start, because you'll eventually want to check for accessibility in any page that a user using a screen reader might visit, but it likely makes sense to start with your most important application routes. Remember that we don't want to add a single accessibility check to each file; we want to test the accessibility for any state that might be triggered, because if we don't test complex behavior we might wind up with a false positive.

For example, let's say we've got checks configured for the basic structure of the User index:

RSpec.describe "User index page links", type: :system do
  it "can load the index" do
    visit users_path

    expect(page).to have_css(".users-index-table")
    expect(page).to have_content("Users")

    expect(page).to have_css(".users-index-links-div")
    expect(page).to have_content("Create New User")
  end

  it "can visit the user creation page from the users index" do
    visit users_path

    click("Create New User")

    expect(page).to have_current_path(new_user_path)
  end
end

We want to update them to run axe checks:

RSpec.describe "User index loading + links", type: :system do
  it "can load the index" do
    visit users_path

    expect(page).to be_axe_clean
    expect(page).to have_css(".users-index-table")
    expect(page).to have_content("Users")

    expect(page).to have_css(".users-index-links-div")
    expect(page).to have_content("Create New User")
  end

  it "can visit the user creation page from the users index" do
    visit users_path

    click("Create New User")

    expect(page).to be_axe_clean
    expect(page).to have_current_path(new_user_path)
  end
end

Run it, and look at the RSpec output. If there are axe failures, they should appear (more examples on that below). Before we move on to another file, we've got a little bit of a problem to talk about: is there even any point to the second spec in this file right now? It seems like it could be more appropriate in a system spec targeting the User submission form. I'd argue that this isn't the right place to make that change. When dealing with these kinds of hefty refactors, it's important to avoid scope creep, and resisting the urge to fix everything at once will ultimately make it easier to manage all the necessary changes for the project.

Avoiding Scope Creep

As mentioned, the spec we're using is starting to look a little weird. Wouldn't it make more sense to check for accessibility from a system test checking the behavior of the new user form? If I were a dev looking at this, I'd likely want to refactor here, replacing the second spec with a test that confirms that all the links on the Users index load and render without accessibility issues. We'd need to duplicate the contents of page here to avoid re-testing the same Capybara object over and over (but only if we're following 4-phase testing). If we don't duplicate page, Capybara will update the value stored in that variable, and both new_user_page and destroy_user_page below will point to the same object in memory.

it "can load all required links from the users index" do
    visit users_path

    click("Create New User")
    new_user_page = page.dup

    click("Back")

    click("Remove User")
    destroy_user_page = page.dup

    expect(new_user_page).to be_axe_clean
    expect(new_user_page).to have_current_path(new_user_path)

    expect(destroy_user_page).to be_axe_clean
    expect(destroy_user_page).to have_current_path(destroy_user_path)
  end

Don't do this. At least, don't do it here. This test is more useful for a future reader, but it's also scope creep. The time to make changes like this is when you're fixing accessibility issues in this file in a separate commit. For now, all you need to do to get started is to find your failures; improving the code that's causing them will happen soon, but shouldn't be performed here. There'll be time later to improve your test suite, and it's worth it, but it's a danger right now. Just add be_axe_clean to each of your system specs, and run them.

This will probably break most of the specs. Some pages might be fine, which is great news for us because those pages are likely already compliant. We can ignore those for now, and focus on the failing cases. When we achieve full accessibility later on, those pages should still be fully functional. In the interim, we need to use some automation to make it possible to migrate the codebase to a compliant standard.

Lean on RSpec

At this point, you should have a list of accessibility failures in your RSpec output. We need to document those failures, and prepare to split up the work into multiple small PRs. To do that, we need a unique list of all the failures present in our app.

When we did this recently, we made a giant Google sheet where each accessibility failure was documented as follows:

Spec File Line Path To View Failure
spec/system/users_spec.rb 8 users_path page-has-heading-one: Page should contain a level-one heading (moderate)

Keeping a detailed list of failures will help you to split up the work into discrete tasks. We chose to break up our work by specific axe failure, because that produced PRs that contained similar changes, and let us take advantage of RSpec matcher functionality.

However you organize it, you need to assemble a list of the distinct failures that are present in your app. Each failure represents the name of a violation present in the Deque axe rules.

In my application, we had the following list after we followed this process:

known_failures = [
  :label,
  :list,
  :listitem,
  :region,
  "aria-allowed-attr",
  "aria-dialog-name",
  "aria-required-children",
  "aria-required-parent",
  "color-contrast",
  "heading-order",
  "label-title-only",
  "nested-interactive",
  "page-has-heading-one",
  "scrollable-region-focusable"
]

Each of these failures represents the name of an axe rule, and might be familiar to you from the spec output returned after adding be_axe_clean.

By assembling a similar list for your app, you can create a spec helper method that allows you to run your spec suite without failures due to known issues, meaning that you can make progress in resolving accessibility issues without drowning in failed tests.

def be_axe_clean_for_upgrade
  known_failures = [
    :label,
    :list,
    :listitem,
    :region,
    "aria-allowed-attr",
    "aria-dialog-name",
    "aria-required-children",
    "aria-required-parent",
    "color-contrast",
    "heading-order",
    "label-title-only",
    "nested-interactive",
    "page-has-heading-one",
    "scrollable-region-focusable"
  ]
  be_axe_clean.skipping known_failures
end

If you configured spec/support/system_test_configuration.rb in Part Two, that might be a good spot for it. Once you've added this method somewhere, go ahead and use global find-replace to replace all instances of be_axe_clean with be_axe_clean_for_upgrade. When you re-run your spec, it should now be green (unless you missed one :D).

This is the place where you break your current work out into a feature branch to be used as a target for all the accessibility PRs. Do that now.

RSpec Stamp Is Neat

There's one more tool that may be useful here. RSpec Stamp is a tool provided by TestProf that adds a custom tag to failing specs when run. It "stamps" them, so you can run them in isolation.

This step is optional! If you don't want to bother with the extra complexity, you can achieve the same effect by manually adding axe: true to each spec that checks accessibility. That looks like this:

  it "can visit the user creation page from the users index", axe: true do
    visit users_path

    click("Create New User")

    expect(page).to have_current_path(new_user_path)
  end

This will let you run all the relevant spec cases at once with bundle exec rspec --tag axe: true.

However, adding that everywhere is annoying and you can use RSpec Stamp to automate it using a shared context file. To set this up:

    1. Install TestProf (which also includes lots of other goodies):
group :rspec_tests, :test do
  gem 'test-prof'
end
    1. Create a shared context for accessibility failures in spec/support/shared_contexts/axe_violation.rb. Move the current spec helper method containing your list of known failures inside of that context file.
RSpec.shared_context "axe violations", axe: :violation do
  around(:each) do |ex|
    def be_axe_clean_for_upgrade
      known_failures = [
        :label,
        :list,
        :listitem,
        :region,
        "aria-allowed-attr",
        "aria-dialog-name",
        "aria-required-children",
        "aria-required-parent",
        "color-contrast",
        "heading-order",
        "label-title-only",
        "nested-interactive",
        "page-has-heading-one",
        "scrollable-region-focusable"
      ]
    be_axe_clean.skipping known_failures
  end
end
    1. You need to tell RSpec to include this shared context in any file that includes a specific tag. Nothing currently has that tag, which is what we're adding with RSpec Stamp. Add this at the top of the context file you just created:
  RSpec.configure do |rspec|
    rspec.include_context "axe violations", axe: :violation
  end
    1. You need to preserve the behavior of passing specs, so you'll need to define a value for be_axe_clean_for_upgrade in spec_helper.rb or system_test_configuration.rb. This will execute for any specs that are not tagged with axe: :violation. It should just be this:
def be_axe_clean_for_upgrade
  be_axe_clean
end
    1. Run RSpec, and everything should be broken again. We just added a shared context, which will only get triggered when a specific tag is present on the spec case. Each failing spec is using the simpler version of be_axe_clean_for_upgrade, and is correctly reporting that it fails the checks you're running. To get that spec case to green, run the following command:
RSTAMP=axe:violation bundle exec rspec

This will modify each failing spec, adding axe: :violation as a tag. If you re-run RSpec, everything should pass (if it doesn't, RSpec Stamp may have missed something). Now you can run all the specs that include violations using bundle exec rspec --tag axe:violation, and modify your shared context to trigger and resolve specific failures without altering behavior for everything else. In addition, any new specs added to the repo will go through the full set of axe checks, avoiding the need to play Whack-A-Mole with issues that were solved in an earlier PR.

Fix One Thing At a Time

If you've been following along, you should now have reproducible failing system spec cases, links to resources explaining why they're failing, a feature branch configured to break all the system specs that are problematic, and a method you can alter to run specific violations. You also probably have a tagged spec suite, with specs that look like this:

# The axe tag here may be axe: true, or just :axe, depending on your approach.
it "can visit the user creation page from the users index", :system, axe: :violation do
  visit users_path

  page_before_click = page

  click("Create New User")

  expect(page_before_click).to be_axe_clean_for_upgrade
  expect(page).to have_current_path(new_user_path)
  expect(page).to be_axe_clean_for_upgrade
end

Now you can fix them atomically. Update the shared context or helper method (if you didn't use RSpec stamp), replacing the body of be_axe_clean_for_upgrade with the following:

 def be_axe_clean_for_upgrade
  be_axe_clean.checking_only("whatever-error-you're-working-on-currently")
    #   known_failures = [
    #     :label,
    #     :list,
    #     :listitem,
    #     :region,
    #     "aria-allowed-attr",
    #     "aria-dialog-name",
    #     "aria-required-children",
    #     "aria-required-parent",
    #     "color-contrast",
    #     "heading-order",
    #     "label-title-only",
    #     "nested-interactive",
    #     "page-has-heading-one",
    #     "scrollable-region-focusable"
    #   ]
    # be_axe_clean.skipping known_failures
  end

Update the HTML and specs, referring to the docs as needed, and keep going until that specific failure is fixed everywhere. Restore be_axe_clean_for_upgrade to its initial value, delete the error you fixed from the list of known violations, and re-run the spec suite. It should now be green.

Repeat this process until you don't have any violations. This may take a lot of time and many commits, but it's really, really worth it. Simple code is clean code, and screen readers want you to write simple, intelligible markup. As you go through your app and fix any violations you may find, you'll usually be removing unneeded complexity from your views, which is great for their long-term health and ease of intelligibility. Once you've identified the problem, the fix is usually "delete stuff you don't need."

What If It Can't Be Fixed?

Fixing everything in a timely manner may not be possible, especially if you're working with a large application or the error is caused by a specific dependency. For instance, we have a jQuery plugin on our search page that controls a few select fields. It's not friendly to screen-readers, but "rewriting the search UI and behavior" wasn't a reasonable goal.

If you've got situations like this, the best thing you can do is track it. The best way to do THAT is to use a custom RSpec matcher. We use this:

RSpec::Matchers.define :match_known_axe_violations do |violations, violation_selectors|
  include Capybara::DSL
  match do |page|
    passes_if_violations_skipped?(page) && !passes_when_targeting_violation?(page)
  end

  failure_message do
    base_message = "Known violations: #{Array.wrap(violations).join(', ')}\n"\
                   "Violation selector: #{Array.wrap(violation_selectors).join(', ')}\n"

    if !passes_if_violations_skipped?(page)
      "#{base_message}Audit failed outside of known violation(s)!\n"\
        "#{audit_skipping_violation.failure_message}"
    elsif passes_when_targeting_violation?(page)
      "#{base_message}Audit did not fail when targeting violations, has this been fixed?"
    else
      super
    end
  end

  define_method :audit_skipping_violation do
    @audit_skipping_violation ||= be_axe_clean.skipping(violations)
  end

  define_method :audit_targeting_violation do
    @audit_targeting_violation ||= be_axe_clean.checking_only(violations)
  end

  def passes_if_violations_skipped?(page)
    audit_skipping_violation.matches?(page)
  end

  def passes_when_targeting_violation?(page)
    audit_targeting_violation.matches?(page)
  end
end

The matcher runs axe twice. On the first iteration, it will skip a specific violation based on the arguments passed to the matcher. On the second iteration, it will make sure that the violation passed to the matcher is returned by the current version of the page. It accepts a selector for documentation purposes, although it doesn't specifically target the issue.

After saving this in spec/support/matchers/, you can replace be_axe_clean_for_upgrade in locations where something cannot be fixed. In this example, the page has a violation within scrollable-region-focusable, located at the selector path indicated.

it "can visit the user creation page from the users index", :system, axe: :violation do
  visit users_path

  index_page = page.dup

  click("Create New User")

  new_user_page = page

  expect(index_page).to be_axe_clean
  expect(new_user_page).to have_current_path(new_user_path)
  expect(new_user_page).to match_known_axe_violations(
    [:region, "scrollable-region-focusable"],
    "body > div:nth-child(4), select.selectpicker",
  )
end

So we now know that this is an issue, can detect the problem when we fix it, and have a record of what failed. Therefore, we can fix the easy things, ensure that new code stays accessible, and incorporate more significant refactors into project planning.

Once you've fixed everything, you can use global find and replace to find the tags and matchers we added here and remove them from the feature branch containing your accessibility changes before merging.

Wrapping Up

Fundamentally, implementing accessibility is easiest when working in small, discrete chunks that can be broken apart and evaluated in isolation. Implementing an accessibility standard is a serious task, and it needs to be thought through and documented to avoid losing your mind on slow, failing tests.

Hopefully, this series will be helpful to the next person that googles "rails how to accessibility" and finds an overwhelming amount of information. With some simple organization and a few RSpec tricks, it's fully possible to make a completely compliant web application (or at least get close enough that that goal is in sight).