Testing from the Outside-In

Joël Quenneville

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: