Rails Feature Test Solved by Regex

Published 01 Nov 2021 · 4 min read
An example where regex was a good solution for a Rails feature test.

If you’ve been programming for awhile, you’ve probably encountered regular expressions, and more specifically, this saying:

Some people, when confronted with a problem, think “I know, I'll use regular expressions.” Now they have two problems.

There's some discussion as to the origin of this saying here. Personally, I approach the use of regular expressions (aka regex) in my code with some trepidation, as they can be difficult to read, and I prefer to leave clean, easy to read code for the future developers (including myself) who will maintain the code.

However, sometimes it is the optimal choice. This post will walk through an example, where using a regex was useful in expressing an expectation in a feature test for a Rails project, plus a nice Ruby way of writing the regex that makes it easier to read.

The Problem

The application I’m working on allows users to setup multi-factor authentication. The last screen of the setup displays a recovery code that the user can enter in the event that they get locked out of their account. This code is a series of 16 randomly generated numbers and letters, displayed in chunks of 4, each separated by a space for legibility. Here's a sample recovery code:

1f08 6a11 b093 8fd6

After a PR got merged that made some changes to the multi-factor flow, a bug was introduced where the recovery code was no longer being displayed. The user would still get the screen instructing them to save the recovery code, but where the code should be displayed was a blank.

Investigation revealed that the recovery code was still being generated by the server, but a UI bug was preventing it from being displayed. The fix was straightforward, however, this application has a very thorough suite of feature tests and I was surprised that one of the tests had not caught this bug.

Feature Test

Turns out there was a feature test that runs through the multi-factor auth steps, but it only verified that the user landed on the recovery screen by verifying the page title. It did not verify that the recovery code was actually displayed. As part of fixing this bug, this test had to be enhanced to also verify the display of the recovery code.

Here is a portion of the markup that displays the recovery code:

<p>Code: <strong>1f08 6a11 b093 8fd6</strong></p>

To make it easier to test, I first added a data-test attribute to the element containing the recovery code:

<p>Code: <strong data-test="recovery-code">1f08 6a11 b093 8fd6</strong></p>

Next I needed to add a step to the feature test to retrieve the element containing the recovery code by data-test selector and verify its contents. Capybara's have_selector RSpec matcher is useful for finding an element by selector and text value. For example, given the following markup:

<div data-test="message">
  Hello
</div>

A feature test could select this element and verify its contents as follows:

expect(page).to have_selector("div[data-test='message']", text: "Hello")

Regex

In the case of the recovery code, the text value is randomly generated so it won’t work to have a static value in the text option for the have_selector matcher. What's needed is a way to express: The recovery code should look like 4 alpha numeric characters, followed by a space, followed by 4 more alpha numeric characters, and so on. This is where a regex is a good solution. It turns out, the text option of Capybara's have_selector also accepts a regular expression. So the test to verify that the recovery code is displayed needs to look something like this:

# This expectation will pass if the string in element recovery-code matches the regex /TBD/.
expect(page).to have_selector("strong[data-test='recovery-code']", text: /TBD/)

To match the recovery code accurately, the regex needs to match on any 4 letters or numbers, followed by a space, followed by any 4 letters or numbers, up to 4 chunks of these. To match any letter or number the \w shorthand can be used, which is equivalent to [0-9a-zA-Z_]. To specify 4 of these, the range repetition syntax {n} can be used. For example to specify 4 characters in a row: \w{4}. To match the whitespace, \s is used. Putting this all together:

expect(page).to have_selector("strong[data-test='recovery-code']", text: /\w{4}\s\w{4}\s\w{4}\s\w{4}/)

While this works, it's difficult to read. Fortunately, Ruby has an alternate syntax for specifying a regex that is not whitespace sensitive. To use it, wrap the regex in %r{...} instead of /.../, then add the x modifier which makes it ignore whitespace. This allows the regex to be split up among multiple lines, and it can even have comments beside each line. So the previous line can be rewritten as:

recovery_code_format = %r{
  \w{4}     # Any 4 characters
  \s        # Whitespace character
  \w{4}     # Any 4 characters
  \s        # Whitespace character
  \w{4}     # Any 4 characters
  \s        # Whitespace character
  \w{4}     # Any 4 characters
}x
expect(page).to have_selector("strong[data-test='recovery-code']", text: recovery_code_format)

This is much more legible and easier to maintain should the recovery code format change in the future.

Interactive Regex

Unless you're writing regex's constantly, it's unlikely your first attempt will be correct. In the example covered in this post, it's being used as part of a Capybara feature test. Since feature tests are slower to run, it will take a relatively long time to get feedback on if the regex is working.

To speed things up, launch a Rails or IRB console to try out the regex interactively, using the =~ operator to determine if a regex matches a string. This operator returns the index occurrence of the first match or nil if there is no match. For example:

irb(main):001:0> recovery_code = "1f08 6a11 b093 8fd6"
irb(main):002:0> recovery_code =~ /\w{4}\s\w{4}\s\w{4}\s\w{4}/
=> 0   # This means a match was found at position 0 of recovery_code.
# test some more inputs...

Conclusion

This post has covered a use case for regex in a Capybara feature test for Rails, how to make it legible, and how to try it out in the console to get quick feedback.