This is a guest blog post from Chris Kottom, a longtime Ruby and Rails developer, freelancer, proud dad, and maker of the best chili con carne to be found in Prague. He’s currently working day and night on the upcoming e-book The Minitest Cookbook.
A lot of Rubyists and Rails developers use a behavior-driven (BDD) workflow to give structure to feature development. Adoption of BDD using tools like RSpec and Cucumber has been fueled in part by the availability of high-quality books and blog posts on the subject, but RSpec is certainly not the only game in town. Many programmers have begun looking for alternative approaches, for example, based on the Minitest unit testing library which is included in Ruby distributions and provides similar functionality in a more compact implementation.
In this post, we’ll walk through a complete feature implementation and show how to build realistic functionality into our application using our tests to lead us through the process. If you haven’t read the first part of the series where we described how to assemble and get started using a lean and mean Rails testing stack based on Minitest, you should consult that post first since the examples in this post pick up where we left off.
Feature: Display a List of To-Do Items
In part one, we implemented an empty list of to-do items. Now we’re ready to display a full list of items from the database. To do this, we’ll update the tests to verify that all to-do items are shown in the list and that the name and description of each item is displayed. The updated feature to look like this:
require "test_helper"
feature "To Do List" do
scenario "displays a list of to-do items" do
visit root_path
page.must_have_css("#items")
within("#items") do
Item.find_each do |item|
selector = "#item-#{ item.id }"
page.must_have_css(selector)
within(selector) do
page.must_have_content item.name
page.must_have_content item.description
end
end
end
end
end
Once we’ve done that, we re-run our suite and see that our feature spec is once again failing.
ItemsController::GET :index
test_0001_renders items/index PASS (0.06s)
test_0002_responds with success PASS (0.01s)
To Do List Feature Test
test_0001_displays a list of to-do items ERROR (0.04s)
NameError: NameError: uninitialized constant Item
test/features/to_do_list_test.rb:8:in `block (3 levels) in '
test/features/to_do_list_test.rb:7:in `block (2 levels) in '
test/features/to_do_list_test.rb:8:in `block (3 levels) in '
test/features/to_do_list_test.rb:7:in `block (2 levels) in '
Finished in 0.12654s
3 tests, 3 assertions, 0 failures, 1 errors, 0 skips
In strict BDD, we’d want to step through this very slowly and one step at a time, but to speed things along and keep from having to write separate migrations for each and every attribute separately, we’ll collapse what we currently know about to-do list items and their attributes into a single failing test case.
require "test_helper"
describe "Item" do
before do
@item = Item.new(name: "Write Minitest-BDD post",
description: "Show Rails and Capybara example")
end
it "has a name attribute" do
@item.must_respond_to :name
end
it "has a description attribute" do
@item.must_respond_to :description
end
end
The test syntax you see here is provided by Minitest::Spec which provides a declarative API for describing the expected state of objects. In this example, I can call #must_respond_to on any object with a Symbol or String argument to test whether or not the object responds to the named method. The Minitest::Spec DSL is very compact by itself, but it’s trivial to extend t with your own custom expectations that will improve the readability and expressiveness of your tests.
These new model tests will fail for the same reason as the controller and feature tests: there’s no Item model yet defined.
ItemsController::GET :index
test_0001_renders items/index PASS (0.06s)
test_0002_responds with success PASS (0.01s)
To Do List Feature Test
test_0001_displays a list of to-do items ERROR (0.04s)
NameError: NameError: uninitialized constant Item
test/features/to_do_list_test.rb:8:in `block (3 levels) in '
test/features/to_do_list_test.rb:7:in `block (2 levels) in '
test/features/to_do_list_test.rb:8:in `block (3 levels) in '
test/features/to_do_list_test.rb:7:in `block (2 levels) in '
Item
test_0002_has a description attribute ERROR (0.00s)
NameError: NameError: uninitialized constant Item
test/models/item_test.rb:5:in `block (2 levels) in '
test/models/item_test.rb:5:in `block (2 levels) in '
test_0001_has a name attribute ERROR (0.00s)
NameError: NameError: uninitialized constant Item
test/models/item_test.rb:5:in `block (2 levels) in '
test/models/item_test.rb:5:in `block (2 levels) in '
Finished in 0.13013s
5 tests, 3 assertions, 0 failures, 3 errors, 0 skips
All of the current errors will be resolved by generating a new Item model with :name and :description attributes and running migrations. Our three errors collapse into one failure.
Item
test_0001_has a name attribute PASS (0.01s)
test_0002_has a description attribute PASS (0.00s)
ItemsController::GET :index
test_0001_renders items/index PASS (0.30s)
test_0002_responds with success PASS (0.01s)
To Do List Feature Test
test_0001_displays a list of to-do items FAIL (0.05s)
Minitest::Assertion: expected to find #item-298486374.
test/features/to_do_list_test.rb:10:in `block (4 levels) in '
test/features/to_do_list_test.rb:8:in `block (3 levels) in '
test/features/to_do_list_test.rb:7:in `block (2 levels) in '
Finished in 0.38305s
5 tests, 6 assertions, 1 failures, 0 errors, 0 skips
We now need to modify the view to display our list of items, and just to speed things up here, we’ll break with strict BDD and once again combine individual changes to the view to include the name and description of the item as well as the id selector that we’ll need. In reality, our view might end up looking like this:
erb
<%%= content_tag :div, class: 'items' do %>
<%% @items.each do |item| %>
<%%= content_tag :p, id: "item-#{ item.id }", class: 'item' do %>
<%%= content_tag :b, item.name %>
<%%= item.description %>
<%% end -%>
<%% end -%>
<%% end -%>
In the end, we end up with errors across all tests that touch the view.
Item
test_0002_has a description attribute PASS (0.00s)
test_0001_has a name attribute PASS (0.00s)
ItemsController::GET :index
test_0001_renders items/index ERROR (0.11s)
ActionView::Template::Error: ActionView::Template::Error: undefined method `each' for nil:NilClass
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___267186296561738095_69977433372580'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___267186296561738095_69977433372580'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
test_0002_responds with success ERROR (0.00s)
ActionView::Template::Error: ActionView::Template::Error: undefined method `each' for nil:NilClass
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___267186296561738095_69977433372580'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___267186296561738095_69977433372580'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
To Do List Feature Test
test_0001_displays a list of to-do items ERROR (0.01s)
ActionView::Template::Error: ActionView::Template::Error: undefined method `each' for nil:NilClass
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___267186296561738095_69977433372580'
test/features/to_do_list_test.rb:5:in `block (2 levels) in '
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___267186296561738095_69977433372580'
test/features/to_do_list_test.rb:5:in `block (2 levels) in '
Finished in 0.14093s
5 tests, 2 assertions, 0 failures, 3 errors, 0 skips
The cause of these failures is the same in all cases: the collection of to-do items is not yet being set by the controller. To resolve this, we first write a failing test on the controller that checks that the collection is assigned and has the right values.
it "fetches and assigns a list of to-do items" do
assigns(:items).wont_be_nil
item_ids = assigns(:items).map(&:id).sort
item_ids.must_equal Item.pluck(:id).sort
end
Here again, we see more of the Minitest::Spec DSL in action. Minitest provides negative expectations like #wont_be_nil to check for the absence of a given condition – in this case, that the object under test is non-nil.
Unlike RSpec, Minitest doesn’t provide a way of testing whether two Arrays contain the same elements, but this is easy enough to implement in the case of ActiveRecord relations simply by comparing two sorted arrays of IDs as shown in the example.
Including this test adds one more failure to our suite:
To Do List Feature Test
test_0001_displays a list of to-do items ERROR (0.14s)
ActionView::Template::Error: ActionView::Template::Error: undefined method `each' for nil:NilClass
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/features/to_do_list_test.rb:5:in `block (2 levels) in '
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/features/to_do_list_test.rb:5:in `block (2 levels) in '
Item
test_0001_has a name attribute PASS (0.00s)
test_0002_has a description attribute PASS (0.00s)
ItemsController::GET :index
test_0001_renders items/index ERROR (0.00s)
ActionView::Template::Error: ActionView::Template::Error: undefined method `each' for nil:NilClass
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
test_0002_responds with success ERROR (0.00s)
ActionView::Template::Error: ActionView::Template::Error: undefined method `each' for nil:NilClass
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
test_0003_fetches and assigns a list of to-do items ERROR (0.00s)
ActionView::Template::Error: ActionView::Template::Error: undefined method `each' for nil:NilClass
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
app/views/items/index.html.erb:2:in `_app_views_items_index_html_erb___1343446998779429432_69831990148440'
test/controllers/items_controller_test.rb:6:in `block (3 levels) in '
Finished in 0.15893s
6 tests, 2 assertions, 0 failures, 4 errors, 0 skips
And now we only need to update the controller action to make the tests pass:
class ItemsController < ApplicationController
def index
@items = Item.all
end
end
And drumroll please…
Item
test_0001_has a name attribute PASS (0.00s)
test_0002_has a description attribute PASS (0.00s)
To Do List Feature Test
test_0001_displays a list of to-do items PASS (0.15s)
ItemsController::GET :index
test_0001_renders items/index PASS (0.00s)
test_0002_responds with success PASS (0.00s)
test_0003_fetches and assigns a list of to-do items PASS (0.00s)
Finished in 0.16973s
6 tests, 13 assertions, 0 failures, 0 errors, 0 skips
This simple example only scratches the surface of what Minitest can do, of course, but it shows off its status as a viable alternative to RSpec. Minitest has a strong and growing ecosystem surrounding it that makes further customization and extension possible to suit your workflow – whether that’s BDD or any other. If you’re looking for a slim alternative to RSpec, it might be worth checking out.
For more information about Minitest and the related gems used in this post, check out some of the following resources:
* Minitest – GitHub, RDoc
* Capybara – GitHub, RDoc
* minitest-rails – GitHub
* minitest-rails-capybara – GitHub