Michael Anhari

Test-Driven Development (TDD) with Rails 5: System Testing

Open laptop in front of a window.

Rails 5.2 ships with a pretty great testing stack right out of the box these days, and this post will be the first in a series where we develop a blog using an outside-in TDD workflow.

Installing Rails 5.2.0

We'll be using Rails 5.2.0. Here's the command you can use to make sure you're on the same version as me.

$ gem install rails -v 5.2.0
$ rails -v
Rails 5.2.0

Initializing our application

We're going to use TDD to build a Rails-powered blog.

$ rails new blog
$ cd blog
$ git add .
$ git commit -m "Initialize Rails 5 Repository"

Set up the development environment and generate the database:

$ bin/setup
$ bin/rails db:migrate
$ git commit -m "Initialize schema"

Our first test

Alright let's get this baby going. We're going to use an outside-in approach. In other words, we'll start by testing our app in a browser, and then drill down into testing the internals of the application.

Let's think about the steps we want a user to take to submit a new article. In my mind, something like this seems pretty reasonable:

  1. Visit a page listing all of the current articles (an index of articles if you will).
  2. Clicking on a link or button to create a new article.
  3. Filling in a title and body for the article.
  4. Clicking a button to save the new article.

In Rails 5, this type of test is referred to as a system test, but you may also see them referred to as "feature" tests. System tests in Rails 5 are built upon Capybara, which provides a nice DSL (Domain Specific Language) for interacting with the browser.

Let's create our first system test to test the behavior we outlined above:

# test/system/user_creates_an_article_test.rb

require "application_system_test_case"

class UserCreatesAnArticleTest < ApplicationSystemTestCase
  test "user creates an article" do
    visit articles_path
    click_on "New Article"
    fill_in "Title", with: "Best article ever"
    fill_in "Body", with: "Best article ever. No seriously."
    click_on "Create Article"

    assert_text "Best article ever"
    assert_text "Best article ever. No seriously."
  end
end

Lets try running the test:

$ bin/rails test test/system/user_creates_an_article_test.rb

Did Google Chrome pop up in a jarring way? By default, Rails will run system tests using Chrome's graphical interface. We can run our system tests in a headless browser that execute our tests in a browser without displaying browser interactions. The chromedriver-helper gem in our Gemfile gives us access to this out of the box.

Configuring our test driver

Notice that our test inherited from ApplicationSystemTestCase. This base class is defined in test/application_system_test_case.rb:

require "test_helper"

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

Here we can see the configuration used for our system tests. Currently our system tests are driven by selenium via chrome, but we can change this to use headless chrome:

require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome
end

# NOTE: the screen_size option is not necessary when using a headless browser.

Getting past our first failure

Now that we're running tests in a more reasonable manner, lets take a look at the first error that should have popped up for our test:

$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
NameError: undefined local variable or method 'articles_path'

It looks like we used an unidentified route helper articles_path in our test and ran into a NameError, which should be of no surprise. We haven't written any application code yet. The approach we are taking is using code that we wish we had. This is how developers use feedback from tests to arrive at the code they desire.

Lets add the minimal amount of code to either make the code pass or present a new failure. Since route helpers, like articles_path, are methods that are generated by Rails under the hood when you define routes in your application (in config/routes.rb) lets start there.

# config/routes.rb
Rails.application.routes.draw do
  resources :articles
end

If we re-run our test we should see something new:

$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::RoutingError: uninitialized constant ArticlesController

Woo! But let's think about the change we implemented. Adding resources :articles will generate routing and helpers for all of the RESTful actions (index, show, new, create, edit, update, and destroy). We can see this by running rails routes:

      Prefix Verb   URI Pattern                   Controller#Action
   articles GET    /articles(.:format)           articles#index
            POST   /articles(.:format)            articles#create
new_article GET    /articles/new(.:format)        articles#new
edit_article GET    /articles/:id/edit(.:format)  articles#edit
    article GET    /articles/:id(.:format)        articles#show
            PATCH  /articles/:id(.:format)        articles#update
            PUT    /articles/:id(.:format)        articles#update
            DELETE /articles/:id(.:format)        articles#destroy
            ...

If we only want to implement code that our tests guide us toward, then we should only implement the index route for this resource since we're trying to GET the articles_path.

Lets change our router to only implement the index action and re-run the test:

# config/routes.rb
Rails.application.routes.draw do
  resources :articles, only: [:index]
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::RoutingError: uninitialized constant ArticlesController

We got the same error which indicates we implemented more behavior than what was required to drive the test forward. How dogmatically you use TDD is really up to you, but I tend to try to stick to it as best as I can.

Following subsequent errors to finish our feature

Our new error indicates that we're missing a controller that Rails is trying route our request to.

Lets create an articles controller and rerun our test:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
AbstractController::ActionNotFound: The action 'index' could not be found for ArticlesController

To progress the test, lets implement an index action (a special name for a method within a Rails controller) in our ArticlesController

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::UnknownFormat: ArticlesController#index is missing
a template for this request format and variant.

request.formats: ["text/html"]
request.variant: []

NOTE! For XHR/Ajax or API requests, this action would normally respond with 204
No Content: an empty white screen. Since you're loading it in a web browser, we
assume that you expected to actually render a template, not nothing, so we're
showing an error to be extra-clear. If you expect 204 No Content, carry on.
That's what you'll get from an XHR or API request. Give it a shot.

It looks like Rails is reaching the index action, but can't find a template to render.

Notice that our system test is making a request with a format of "text/html", and this is why Rails is trying to find a template to serve the browser. The NOTE! at the bottom of our test error indicates that API requests will work just fine if made to this controller action.

Rails uses the name of the controller and action to look for a template, so if we create a view in app/views/articles/index.html.erb we should get a new error.

Lets create the following view and rerun the test:

<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
Capybara::ElementNotFound: Unable to find visible link or button "New Article"

We're missing a link to create a new article. Let's add one to our index view and rerun our test:

<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>

<%= link_to "New Article", new_article_path %>
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionView::Template::Error: undefined local variable or method 'new_article_path'

It looks like now is the time to implement a route for generating a new article:

# config/routes.rb
Rails.application.routes.draw do
  resources :articles, only: [:index, :new]
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
AbstractController::ActionNotFound: The action 'new' could not be found for ArticlesController

We'll need a new action in our ArticlesController to route the request:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end

  def new
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::UnknownFormat: ArticlesController#new is missing a template
for this request format and variant.

request.formats: ["text/html"]
request.variant: []

NOTE! For XHR/Ajax or API requests, this action would normally respond with 204
No Content: an empty white screen. Since you're loading it in a web browser, we
assume that you expected to actually render a template, not nothing, so we're
showing an error to be extra-clear. If you expect 204 No Content, carry on.
That's what you'll get from an XHR or API request. Give it a shot.

Look familiar? We need a template for our new action to render:

<!-- app/views/articles/new.html.erb -->
<h1>New Article</h1>
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
Capybara::ElementNotFound: Unable to find visible field "Title" that is not disabled

We have reached the fill_in "Title", with: "Best article ever" step of our test, and it cannot locate a form to create our article. Let's add one using a Rails form helper:

<!-- app/views/articles/new.html.erb -->
<h1>New Article</h1>

<%= form_for @article do |f| %>
  <%= f.label :title %>:
  <%= f.text_field :title %>
  <br />

  <%= f.submit %>
<% end %>
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionView::Template::Error: First argument in form cannot contain nil or be empty

The first argument to the form_for helper is currently @article, but we haven't set that instance variable in our new action yet.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end

  def new
    @article = Article.new
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
NameError: uninitialized constant ArticlesController::Article

We're finally at the point where we need to create a Rails model to store our form's contents to the database. We referenced Article.new in our controller's new action because that's the code we wish would work, but our application found an undefined constant Article in the ArticlesController namespace. Let's create an Article model to satisfy the current error:

$ rails generate model article title:string body:text
      invoke  active_record
      create    db/migrate/20180704172516_create_articles.rb
      create    app/models/article.rb
      invoke    test_unit
      create      test/models/article_test.rb
      create      test/fixtures/articles.yml
$ rails db:migrate
== 20180704172516 CreateArticles: migrating ===================================
-- create_table(:articles)
   -> 0.0035s
== 20180704172516 CreateArticles: migrated (0.0036s) ==========================
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
Capybara::ElementNotFound: Unable to find visible field "Body" that is not disabled

The error we received before only ran into issue when looking for the "Title" field. It looks like were at the next step of our system test, fill_in "Body", with: "Best article ever. No seriously.". Let's add a field for our article's body:

<!-- app/views/articles/new.html.erb -->
<h1>New Article</h1>

<%= form_for @article do |f| %>
  <%= f.label :title %>:
  <%= f.text_field :title %>
  <br />
  <%= f.label :body %>:
  <%= f.text_field :body %>
  <br />

  <%= f.submit %>
<% end %>
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::RoutingError: No route matches [POST] "/articles"

We're getting close! Rails is trying to submit our form, but we haven't added a route to POST the data to our database. Let's add one now:

# config/routes.rb
Rails.application.routes.draw do
  resources :articles, only: [:index, :new, :create]
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
AbstractController::ActionNotFound: The action 'create' could not be found for ArticlesController

Starting to feel the flow? This is the third time we've seen a variation of this error. Let's add a create action to our ArticlesController.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end

  def new
    @article = Article.new
  end

  def create
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Failure:
UserCreatesAnArticleTest#test_user_creates_an_article:
expected to find text "Best article ever" in "New Article\nTitle:\nBody:"

Notice anything different about this error? We have finally reached the first assertion of our system test (assert_text "Best article ever")!

Notice that the test returned a Failure message instead of an Error message for the first time.

We're at the point where we are trying to verify that the user can see the article's information that we submitted via the form on the screen after clicking the submit button.

We could cheat here. We could redirect to a view that displays a hardcoded version of the title and body we provided in our test, but I think that would be a bit silly. Instead, let's redirect to the show action to display the new article and follow the errors we get from there.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end

  def new
    @article = Article.new
  end

  def create
    redirect_to article_path(@article)
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
NoMethodError: undefined method `article_path' for #<ArticlesController:...>
Did you mean?  articles_path
Did you mean?  articles_path

Another error for a missing route helper. Let's add a route to show a single article.

# config/routes.rb
Rails.application.routes.draw do
  resources :articles, only: [:index, :new, :create, :show]
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::UrlGenerationError: No route matches
{:action=>"show", :controller=>"articles", :id=>nil}, missing required keys: [:id]

You might have expected an error for a missing show action in the ArticlesController, but it looks like our test blew up at the routing level.

show routes expect a persisted object they can direct to. Right now we're passing @article to the article_path helper, but we never defined it. Let's try setting it to a new article like we did in our new action.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end

  def new
    @article = Article.new
  end

  def create
    @article = Article.new
    redirect_to article_path(@article)
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::UrlGenerationError: No route matches
{:action=>"show", :controller=>"articles", :id=>nil}, missing required keys: [:id]

We're running into the same error, missing required keys: [:id]. If you look at the params show in the error message, you'll see that the id is currently set to nil. That's because we need to pass a persisted article to the route helper.

Rails controllers have a params object that contain the contents that we filled into our form. In fact, let's try using byebug to create a breakpoint in our application and inspect what the params object has:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end

  def new
    @article = Article.new
  end

  def create
    byebug
    @article = Article.new
    redirect_to article_path(@article)
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
[4, 13] in /Users/anhari/Development/blog/app/controllers/articles_controller.rb
    4:   def new
    5:     @article = Article.new
    6:   end
    7:
    8:   def create
    9:     # @article = Article.create(params[:article])
   10:     byebug
=> 11:     redirect_to article_path(@article)
   12:   end
   13: end
(byebug) params
<ActionController::Parameters {
  "utf8" => "✓",
  "article" => {
    "title" => "Best article ever",
    "body" => "Best article ever. No seriously."
  },
  "commit" => "Create Article",
  "controller"=>"articles",
  "action" => "create"
} permitted: false>

Type 'exit' and press Enter to quit byebug; or type 'continue' (or 'c') to finish running the test.

It looks like our parameters are stored in the "article" key. Let's try creating an article using that hash key.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
  end

  def new
    @article = Article.new
  end

  def create
    # NOTE: make sure you remove your byebug statement
    @article = Article.create(params[:article])
    redirect_to article_path(@article)
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActiveModel::ForbiddenAttributesError: ActiveModel::ForbiddenAttributesError

This error is a little cryptic. To understand what's going on here, you have to be aware that Rails is trying to protect your application's security out of the box through something it refers to as Strong Parameters.

A quick interpretation is that, Rails wants you to explicitly state which attributes can be set by this form instead of just creating an article with whatever attributes are passed in the "article" key of the params object.

There's a few way ways we can do this. We could do something like this:

Article.create(
  title: params[:article][:title],
  body: params[:article][:body],
)

Since we are digging individual parameters out of the "article" hash, Rails deems this as safe and acceptable. But there is another way we can do this that is a little more pragmatic. Let's go with this:

class ArticlesController < ApplicationController
  def index; end

  def new
    @article = Article.new
  end

  def create
    @article = Article.create(article_params)
    redirect_to article_path(@article)
  end

  private

  def article_params
    params.require(:article).permit(:title, :body)
  end
end

Here we're moving our explicitly allowed params into a private method that we can re-use if users need to edit and update articles later on.

If we rerun our test, we get the missing controller action we might have expected earlier:

$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
AbstractController::ActionNotFound: The action 'show' could not be found for ArticlesController

We know how to deal this one by now:

class ArticlesController < ApplicationController
  def index; end

  def new
    @article = Article.new
  end

  def create
    @article = Article.create(article_params)
    redirect_to article_path(@article)
  end

  def show
  end

  private

  def article_params
    params.require(:article).permit(:title, :body)
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb

Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionController::UnknownFormat: ArticlesController#show is missing a template
for this request format and variant.

request.formats: ["text/html"]
request.variant: []

NOTE! For XHR/Ajax or API requests, this action would normally respond with 204
No Content: an empty white screen. Since you're loading it in a web browser, we
assume that you expected to actually render a template, not nothing, so we're
showing an error to be extra-clear. If you expect 204 No Content, carry on.
That's what you'll get from an XHR or API request. Give it a shot.

Another familiar test. Let's create a show template:

<!-- app/views/articles/show.html.erb -->
<h1>Single Article</h1>
$ bin/rails test test/system/user_creates_an_article_test.rb
Failure:
UserCreatesAnArticleTest#test_user_creates_an_article:
expected to find text "Best article ever" in "Single Article"

Alright, alright, alright. We just need to display the article's title in our view.

<!-- app/views/articles/show.html.erb -->
<h1><%= @article.title %></h1>
$ bin/rails test test/system/user_creates_an_article_test.rb
Error:
UserCreatesAnArticleTest#test_user_creates_an_article:
ActionView::Template::Error: undefined method `title' for nil:NilClass

We need to set our @article instance variable in our controller's show action.

class ArticlesController < ApplicationController
  def index; end

  def new
    @article = Article.new
  end

  def create
    @article = Article.create(article_params)
    redirect_to article_path(@article)
  end

  def show
    @article = Article.find(params[:id])
  end

  private

  def article_params
    params.require(:article).permit(:title, :body)
  end
end
$ bin/rails test test/system/user_creates_an_article_test.rb
Failure:
UserCreatesAnArticleTest#test_user_creates_an_article:
expected to find text "Best article ever. No seriously." in "Best article ever"

We've hit our second assertion. We just need to display our articles body text in our show view:

<!-- app/views/articles/show.html.erb -->
<h1><%= @article.title %></h1>
<p><%= @article.body %></p>
$ bin/rails test test/system/user_creates_an_article_test.rb
.

Finished in 2.323028s, 0.4305 runs/s, 0.8609 assertions/s.
1 runs, 2 assertions, 0 failures, 0 errors, 0 skips

Victory! Our feature is up and running! In the next post of this series we'll take a look at integration testing. We'll write a few tests that make sure our controller is doing what we expect, and we'll add flash messages to our application.

Cheers friend.

Newsletter

I'm working on sending out a weekly newsletter. I'll make it as easy as possible to unsubscribe at any time.