Test-Driven Development (TDD) with Rails 5: System Testing
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:
- Visit a page listing all of the current articles (an index of articles if you will).
- Clicking on a link or button to create a new article.
- Filling in a title and body for the article.
- 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. TheNOTE!
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 anError
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.