By Artem Avetisyan on January 12, 2022 • 10 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.
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.
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 be able to exercise any javascript. Which is a bit pointless unless our application is also functional without javascript. 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.
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
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.
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.
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
.
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.
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.
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?
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.
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.
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
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.
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).