A few months ago my colleague Josh Steiner wrote a comprehensive post on How We Test Rails Applications, detailing the different types tests we write and the various technologies that go with them. In this follow up, we will take a closer look at thoughtbot’s testing workflow.
We use a process known as “Outside-in testing”, driving our development from high-level tests and working our way down to lower-level concerns. Say we are working on an e-commerce site and want to implement the following story:
As a guest, I can add items to my shopping cart so that I can keep on shopping
Before we start thinking about models, controllers, or other architectural concerns we write a high-level RSpec feature test that describes the behavior from the user’s perspective.
# spec/features/guest_adds items_to_shopping_cart_spec.rb
feature 'Guest adds items to shopping cart' do
scenario 'via search' do
item = create(:item)
visit root_path
fill_in 'Search', with: item.name
click_on 'Search Catalogue'
click_on item.name
click_on 'Add to Cart'
click_on 'Shopping Cart'
expect(page).to have_content(item.name)
expect(page).to have_content("Subtotal: #{item.price}")
end
end
Depending on how much of the application is implemented, this test could break
in multiple places. If this were a newly-generated application we might need to
implement a home page. Once we have a home page we would probably get an error
while attempting to use the search bar saying that ‘No such route exists’. This
leads us to implement a /items
route.
# config/routes.rb
# ...
resources :items, only: [:index]
# ...
The next few errors walk us through creating an ItemsController
, with an
empty index
action and corresponding view. Now that we can successfully click
on “Search Catalogue”, we get an error saying that there the desired item does
not appear in the search results so we expose some items in the controller and
display them in the view.
# app/controllers/items_controller.rb
#...
def index
@items = Item.search(params[:search_query])
end
#...
# app/views/items/index.html.erb
<% @items.each do |item| %>
<%= link_to item.name, item %>
<% end %>
This gives us a new error saying that there is no method search
defined
Item
. At this point, we drop down a level of abstraction and write a unit
test for Item
.
# spec/models/item_spec.rb
describe Item, '.search' do
it 'filters items by the search term' do
desired_item = create(:item)
other_item = create(:item)
expect(Item.search(desired_item.name)).to eq [desired_item]
end
end
This test leads us to correctly implement Item.search
:
# app/models/item.rb
#...
def self.search(term)
where(name: term)
end
#...
Now the unit test passes so we go back up to our feature test. We can successfully click on the item’s name in the search results!
We keep following this pattern for the remaining test failures, dropping down to the unit test level when necessary, until we have a green test suite. Now our story has been successfuly implemented!
Mocking and Stubbing
The goal of a feature test is to test the real system from end-to-end from the user’s perspective. To do this, we use real database records and don’t mock or stub any of our objects. We do stub calls to external websites (via webmock or a fake) since the network can be unreliable. Our tests should run without an internet connection.
When dropping down to the unit test level, we aggressivly mock/stub out dependencies and collaborators. The goal of a unit test is to prove the functionality of the object being tested, not the functionality of its collaborators. Difficulty in testing two objects in isolation from each other often points to too tight coupling between them.
Further Reading
For some more great articles on testing, check out: