[007.1] Building a To-Do List in Rails Via TDD, Part 1

Test-driven development of a production checklist app, starting with acceptance tests.

Subscribe now

Building a To-Do List in Rails Via TDD, Part 1 [12.04.2017]

This week we're going to use test-driven development to build an app that will help developers remember everything they need to do when first deploying an app to production.

We've started with a basic Rails 5.1 app with some testing gems. We’ve also already implemented basic GitHub oauth using OmniAuth and Devise. Finally, we have a basic feature test in place for the home page.

Today we'll write the acceptance tests that describe the expected final behavior of the app.

Test Driven Development, or TDD, is when we start writing our tests and use those failing tests to guide our development on a given feature. It helps us establish an idea of how the objects communicate with each other, and how the dependencies should be structured. Furthermore, it provides a frame for your code, which helps you decide the best way to approach a given problem.

Project

We're starting with the dailydrip/produciton.com repo tagged before this episode.

Our app will be a checklist for developers who are launching a new application into production. We have a User and a Checklist model. The User model leverages Devise and OmniAuth as that’s a great way of implementing a GitHub login quickly. Once a user has logged in they can create checklists for their applications. We’ll start with a few high-level acceptance tests to ensure that our app is solving the problems we need it to.

We'll be using the rspec feature style. Writing tests in this style forces us to think through our application before we code. This lets us figure out the structure of our code before we start writing it, and results in a more maintainable codebase.

On the home screen, we wish to show all of the available checklists. Let's write up a quick test:

mkdir spec/features
vim spec/features/home_screen_spec.rb
require 'rails_helper'

RSpec.feature 'Home screen', type: :feature do
  let!(:ruby) { create(:checklist, title: 'Ruby') }
  let!(:elm) { create(:checklist, title: 'Elm') }

  scenario 'displays checklists' do
    visit root_path

    expect(page).to have_text('Ruby')
    expect(page).to have_text('Elm')
  end
end

We’d like to keep our tests as readable as possible, and reduce duplication. Checklists are a prominant part of our application, so this is a great opportunity to introduce a custom RSpec matcher to help us test those links out.

require 'rails_helper'

RSpec.feature 'Home screen', type: :feature do
  let!(:ruby) { create(:checklist, title: 'Ruby') }
  let!(:elm) { create(:checklist, title: 'Elm') }

  scenario 'displays checklists' do
    visit root_path

    expect(page).to have_checklist_link(ruby)
    expect(page).to have_checklist_link(elm)
  end
end

This won't pass because we haven’t defined the have_checklist_link matcher yet. We already have included support files in our rails_helper.rb.Now we can introduce a basic matcher to ensure we are linking to a checklist.

vim spec/support/custom_matchers.rb
RSpec::Matchers.define :have_checklist_link do |expected|
  match do |actual|
    expect(actual).to have_text(expected)
  end

  description do
    "have a checklist link for #{expected}"
  end
end

When we run the test, it still passes. Let's make it a little bit more specific, by ensuring the text exists inside of the ul.checklists element, inside of a list item, inside of a link:

RSpec::Matchers.define :have_checklist_link do |expected|
  selector = 'ul.checklist-list > li > a'
  match do |actual|
    expect(actual).to have_selector(selector, text: expected.to_s)
  end

  description do
    "have a checklist link for #{expected}"
  end
end

One nice thing custom matchers provide - as we modify how a given component is rendered, we can just update a single spot in the codebase. This means we're more likely to test more complicated interactions rather than just falling back to simplistic solutions.

Perhaps more importantly, it helps us write our tests without necessarily needing to know how we'll implement the matchers. This is useful, because we can now focus exclusively on writing the remaining acceptance tests.

Next, let's move on to what you see when you view a checklist. We expect to see the checklist items, and we expect to be able to complete them:

vim spec/features/checklists_spec.rb
require 'rails_helper'

RSpec.feature 'Checklists', type: :feature do
  # We'll set up a checklist and some items under it
  let!(:ruby) { create(:checklist, title: 'Ruby') }
  let!(:configure_ssl) { create(:checklist_item, title: 'Configure SSL', checklist: ruby) }
  let!(:set_up_cdn) { create(:checklist_item, title: 'Set up CDN', checklist: ruby) }

  scenario 'viewing a checklist' do
    visit checklist_path(ruby)

    expect(page).to have_title("#{ruby} | Produciton")
    expect(page).to have_incompleted_checklist_item(configure_ssl)
    expect(page).to have_incompleted_checklist_item(set_up_cdn)
  end

  scenario 'completing a checklist item' do
    visit checklist_path(ruby)
    complete_checklist_item(configure_ssl)

    expect(page).to have_incompleted_checklist_item(set_up_cdn)
    expect(page).to have_completed_checklist_item(configure_ssl)
  end

  scenario 'completing all checklist items' do
    visit checklist_path(ruby)
    complete_checklist_item(configure_ssl)
    complete_checklist_item(set_up_cdn)

    expect(page).to have_text("Everything's finished!")
  end
end

We should be able to share our checklist with our team members:

  scenario 'sharing a checklist with another user' do
    visit checklist_path(ruby)
    share_checklist_with('bob@example.com')
    expect(page).to have_pending_share_for('bob@example.com')
    accept_share_for('bob@example.com', ruby)
    visit checklist_path(ruby)
    expect(page).to have_accepted_share_for('bob@example.com')
  end

We should be able to add checklist items:

  scenario 'adding a checklist item' do
    visit checklist_path(ruby)
    fill_in '#new_checklist_item_title', with: 'New item'
    click_button 'Add checklist item'
    expect(page).to have_incompleted_checklist_item('New item')
  end

We should be able to remove checklist items:

  scenario 'removing a checklist item' do
    visit checklist_path(ruby)
    expect(page).to have_incompleted_checklist_item(ruby)
    within(checklist_item_selector(ruby)) do
      click_button 'Remove checklist item'
    end
    expect(page).not_to have_incompleted_checklist_item(ruby)
  end

We should be able to edit a checklist item's title right on the page:

  scenario 'editing a checklist item title' do
    visit checklist_path(ruby)
    expect(page).to have_incompleted_checklist_item(ruby)
    within(checklist_item_selector(ruby)) do
      click_link 'Edit'
      fill_in '.checklist_item_title', with: 'Ruby 3'
    end
    expect(page).to have_incompleted_checklist_item('Ruby 3')
  end

This covers the basics of our application's behaviour. Of course none of these pass yet, but now we have a target to shoot for.

Summary

Today we introduced produciton, a shared checklist for launching new applications into production. We implemented a basic acceptance test, learned how to write a custom rspec matcher, and wrote the remaining acceptance tests for our MVP. Tomorrow we'll extract some model tests from what we've learned as well as introduce a bit more functionality. See you soon!

Resources