Turbo and fast system tests

By Artem Avetisyan on January 12, 202210 min read

Rails allows us to build rich UIs without writing Javascript. Rails also allows us to test those UIs without an actual browser.

This post is focused on that last capability. And on how Turbo extends it even further.

But before we begin let’s take a look into why testing UIs without browser is something worth doing. Feel free to skip over the next part if you must see the code now.

Browser testing

Problem

Rails system tests are slow (compared to model and controller tests) because they drive the application via real browser. This affects both start up and execution times. In addition to being slow, browser tests are harder to debug because the state of the app is spread across two independent processes: ruby server and browser client.

Alternative

Rails is using Capybara to drive the UI in system tests. Capybara in turn is using selenium driver to communicate with the browser. There is, however, another driver called rack_test. It plugs Capybara directly into the request middleware stack, bypassing the need to actually run the server and open the browser in order to be able to visit urls, click links, submit forms and so on. Effectively, under rack_test system tests are using the same underlying machinery as controller tests.

This is a cool party trick, but this way Capybara will no longer exercise any javascript. Unless our application is somehow functional without javascript as well. Sounds like a lot of work, but this is where Rails has a few tricks to offer. And even more so with the advent of Turbo.

Rails sticks to server-side rendering and progressive enhancement (PE). The latter is what allows our application to function without javascript. So far PE in Rails was supported by Turbolinks and UJS, but that, without going into details, was a fairly basic tech.

Now meet Turbo, part of the Hotwire family. Turbo has Drive, which is just a more polished, streamlined version of what was already largely possible with Turbolinks + UJS. It also has Frames. Frames is a brand new capability that allows you to update parts of the page independently. Finally there are Streams, another addition that provides a DSL for “gluing” bits of server side rendered html into the dom on the client side.

All the while sticking to server side rendering only. And if everything is rendered by rails controllers, then switching to rack_test doesn’t actually sound like a big deal.

It’s important to note that the end game is not to replace selenium with rack_test, but to be able to run the same tests in both modes. rack_test for speed and ease of debugging, followed by selenium for the ultimate confidence.

With that in mind, let’s see what it takes to achieve this in practice.

Rails system tests

Out of the box, Rails 6/7 system tests pop up a Chrome browser (test/application_system_test_case.rb):

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
end

Going headless

The browser window flashing is a distraction so the first thing to do is to switch to headless mode by default (rails also registers :headless_chrome driver):

require 'test_helper'

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: ENV['GUI'] ? :chrome : :headless_chrome
end

The original GUI version can be turned on with an environment variable: GUI=1 rails test:system. As a bonus, headless tests are slightly faster.

rack_test

Now let’s go for full glory. rails test:system is going to run purely in ruby. JS=1 rails test:system will run in a headless browser and GUI=1 rails test:system will open a regular browser.

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  def self.js?
    ENV['JS'] || ENV['GUI']
  end

  if js?
    driven_by :selenium, using: ENV['GUI'] ? :chrome : :headless_chrome
  else
    driven_by :rack_test
  end
end

rack_test ships with Capybara by default, no need to install extra gems.

Dealing with Javascript

Now that we can run system tests in ruby, let’s see what can be done about JavaScript powered features that can’t be tested with rack_test.

Opting out of rack_test

Some features are more client side than others (for example, embedded payment iframe) to the point that it does not make sense to have a rack_test version. In this case, you can simply wrap related tests in if js? to make them only run in the real browser.

Or you can opt out of rack_test by default and gradually add existing tests in as and when to avoid “all or nothing” upgrade.

Changing tests to support both modes

While it makes sense in some cases, excluding tests from rack_test defeats the whole point. Sometimes the difference between javascript and non-js versions is cosmetic and can be captured in a test helper. That’s a low hanging fruit. For example, javascript confirm dialog only exists in the actual browser. We can make a helper method that will confirm the dialog when the test runs in javascript or otherwise do nothing:

def confirm
  js? ? accept_confirm { yield } : yield
end

And then somewhere in the test:

confirm { click_button 'Destroy' }

Naturally, you’ll find yourself minimizing the differences between javascript and non-js versions of your site and that is actually good thing.

Changing views to support both modes

Imagine a form with a single checkbox that automatically submits whenever checkbox is toggled. This can only be done in JavaScript. For the non-JS version we can add a “Submit” button that is only shown when the form page is viewed without JavaScript.

There is a clever trick to support this kind of extra non-JS controls. Add the following to the top layout file:

<html class="no-js">
  <head>
    <style>.js .js-hidden { display: none; }</style>
    <script>document.documentElement.className = 'js'</script>

When JavaScript is enabled, the script will replace top element class name from no-js to js, thus activating the .js-hidden CSS rule. Now we can add js-hidden class to the submit button (or indeed anything that we don’t want regular users to see):

<%= f.submit, class: 'js-hidden' %>

Then in tests:

click_button 'Submit' unless js?

Changing controllers to support both modes

We can also have the same rails code support different navigations with or without javascript. This is where Turbo comes in handy.

Let’s talk about comments. It’s pretty standard to have an inline reply form appearing underneath the comment when a user clicks “reply”. This can only be done with JavaScript. Let’s call it “Reddit style” comments.

Reddit style comments

Then there is Hacker News. Their version of leaving a comment is majestically simple: new page with a form and a submit button. No JavaScript required.

HN style comments

Note how the url stays the same on reddit versus visiting the “new comment” page and then going back to the post page on hackernews.

Now let’s say your site must have “Reddit style” comments for whatever reason. Can we test them without javascript? The answer is yes, because Turbo lets us implement it without a single line of Javascript and in such way that it falls back to “HN style” almost for free.

Let’s have a look at the implementation.

“Reply” link renders a new comment form. It is implemented as a standard rails CRUD with the following routes:

  resources :comments, only: [] do
    resources :comments
  end

With the above routes reply link looks like this:

<%= link_to 'Reply', new_comment_comment_path(comment) %>

And the controller is pretty standard too:

def new
  @comment = @commentable.comments.new
end

Now the comments/new.html.erb gets some new magic:

<h1>New Comment</h1>

<%= turbo_frame_tag "new_#{dom_id(@commentable)}_comment" do %>
  <%= render 'form', comment: @comment %>
<% end %>

The peculiar turbo_frame_tag does nothing on its own. However, if the part of the page from where “Reply” link was clicked is also wrapped in turbo_frame_tag with the same id, then, instead of performing navigation, Turbo js will replace the contents of the outer frame with the contents of the this one.

So let’s introduce an outer frame by wrapping the “Reply” link (in _comment.html.erb partial) with a matching frame. As a result, clicking “Reply” will replace the link with the new comment form, without navigating away from the comments index:

<%= turbo_frame_tag "new_#{dom_id(comment)}_comment" do %>
  <%= link_to 'Reply', new_comment_comment_path(comment) %>
<% end %>

The form itself is a bog standard Rails one:

<%= form_with(model: [comment.commentable, comment]) do |form| %>
  ...

It posts to the comments controller that in itself is rather conventional apart from one line:

def create
  @comment = @commentable.comments.build(comment_params)

  respond_to do |format|
    if @comment.save
      format.html { redirect_to [@commentable, @comment], notice: 'Comment was successfully created.' }
      format.turbo_stream # <-- turbo magic here!
    else
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

If the form was submitted via turbo, then the controller will attempt to render app/views/comments/create.turbo_stream.erb template. In our case it contains just this one line:

<%= turbo_stream.replace dom_id(@commentable), @commentable %>

Which is a DSL for “find an element on the page with dom_id(@commentable) and replace it with rendered @commentable”. This updates comment’s parent, which naturally contains newly created comment and gets rid of the form. For this to work each comment on a page must be wrapped in a container with its dom_id:

<%= tag.div id: dom_id(comment) do %>
  <div>
    <%= comment.body %>
  </div>

  <div>
    <%= comment.created_at %> |
    <%= turbo_frame_tag "new_#{dom_id(comment)}_comment" do %>
      <%= link_to 'Reply', new_comment_comment_path(comment) %>
    <% end %>
  </div>

  <div>
    <%= render comment.comments %>
  </div>
<% end %>

Now, just like in the case of rendering reply form, this is functionally equivalent to the non-js version, but the presentation is different.

Putting all of the above together, replying to a comment works “Reddit style” with Javascript enabled and falls back to “HN style” otherwise. And so the following system test runs both with selenium and rack_test:

test 'replying to a comment' do
  post = posts(:one)

  visit post_path(post)

  click_link 'Reply', match: :first
  fill_in 'Body', with: 'Bananas'
  click_on 'Create Comment'

  assert_text 'Comment was successfully created' unless js?
  assert_text 'Bananas'
end

Going further

The reason we were able to pull the “reply to comment” trick is that Turbo allows us to implement SPA behavior without writing any Javascript. But there are of course limits to what client side experience can be implemented without Javascript.

This is where you might want to check out Stimulus. It has many qualities, but the one that’s relevant to this post is that Stimululs does not attempt to render html, but merely adds behvavior to the existing (server side rendered) html. And that naturally makes it easier to design solutions that fallback to non-js version.

Check out the previous (pre Turbo) version of this post, where Reddit/HN example is implemented using Turbolinks, UJS and Stimulus to see the example of this.

Conclusion

Rails allows us to build rich client side UIs without writing a lot of Javascript. This, coupled with support for Progressive Enhancement and rack_test, provides an opportunity to reduce the reliance on browser tests. Which is a good thing because browser tests are slow and hard to work with.

All the code snippets above are working. Go ahead and check out the example repo and see it in action on heroku (“Disable JavaScript” in devtools for non-JS version).