Live Blogging a Book Review App 2

Part 2: Authors

In the last post in this live blog series we started a journey to create a book review app by going through the excitement of the rails new command. Our app running locally looks something like this:

On the plus side there are no bugs. But, there are also no features.

It's time to build some functionality into our application. We will start with the ability to add an author to the database. This will be a fairly straightforward exercise in coding create, read, update, delete (crud) functionality - an area where the power of Rails convention over configuration really shines. The goal moving forward in this post is to show my actual process in implementing our author, even when things go off the rails a bit. You see what I did there?

Feature Testing

We will start with a feature test because this will help us stay focused on solving specific issues and delivering incremental value through each iteration of this bookshelf application.

Start by making a new git branch:

$ git checkout -b iteration_1_authors_genres

Next create a test file for our feature test:

$ mkdir spec/features

$ touch spec/features/author_spec.rb

Now write a feature test in which we visit, fill out, and submit the author form. We will make assertions that the content we expect to see after submitting the form includes the newly created author's name and date of birth.

# spec/features/author_spec.rb
require 'rails_helper'

feature 'User creates an author' do
  scenario 'they see the author on the page' do
    visit new_author_path

    fill_in "First name", with: 'Frank'
    fill_in "Last name", with: 'Herbert'
    fill_in "Date of birth", with: '1920/10/08'
    click_button 'Create Author'

    expect(page).to have_content 'Frank Herbert'
    expect(page).to have_content 'October 10, 1920'
  end
end

To run this test we type the following into terminal.

$ rspec spec/features/author_spec

Running the above test got me the following madness:

Screen Shot 2017-10-25 at 9.35.02 PM.png

The main error reads:

ActiveRecord::NoDatabaseError: FATAL: database "bookshelf_test" does not exist

We need to create our database!

$ rake db:create

Running the test again gets us a much more reasonable failure:

Screen Shot 2017-10-25 at 9.40.03 PM.png

This test is failing on the very first line of the test, ./spec/features/author_spec.rb:6, because we do not have a new_author_path defined. Time to make a route.

Baby's First Route

Rails gives us a config/routes.rb and an entire domain specific language (DSL) for creating routes. We can start by adding an authors resource.

# config/routes.rb
Rails.application.routes.draw do
  resources :authors
end

We can check our new routes by entering the following into our terminal:

$ rake routes

Screen Shot 2017-10-25 at 9.46.52 PM.png

Adding two words to our routes file gets us eight new routes! Thanks DHH. Notice the third route in our list author#new with prefix new_author. I expect this to solve our undefined local variable error in our last test. Running the test now should give us:

ActionController::RoutingError:

uninitialized constant AuthorController

Rails Scaffolds

Rails scaffolds are templates of files and directory structures that are conventional to Rails applications. Running a scaffold will automatically generate files based on these templates, which saves time. To quickly refresh on the plethora of options available to us by way of rails scaffolds run:

$ rails g scaffold -h

This will run the help command for rails generate scaffold and give us the following description:

Scaffolds an entire resource, from model and migration to controller and views, along with a full test suite. The resource is ready to use as a starting point for your RESTful, resource-oriented application.

There are lots of options when running generating a scaffold.

$ rails g scaffold author first_name:string last_name:string date_of_birth:date --no-styelsheets --no-assets --no-scaffold-stylesheet --no-controller-specs --no-view-specs --no-helper-specs --no-helper --no-javascripts --no-jbuilder

The above command tells rails to generate a scaffold for our author model. We specify that the author has two string attributes - first name and last name - and one date attribute. I was pretty aggressive with the options I selected. I do not intend to stylize this application on a per model basis so anything dealing with stylesheets, assets, and javascript I skip. The alternative would be having a bunch of empty css and js files polluting our file structure.

In terms of my testing options, I choose not to generate controller or view specs. Controllers in this app will be so lightweight, they will not really be worth testing. Plus we will have route testing. View testing will also be minimal and subsumed mostly by our feature testing.

Migrate

The rails scaffold we just ran created a pending migration, which is another DSL in Rails for describing how to change our database. In this case we will be creating a table called authors, with fields for first name, last name, and date of birth with data types string, string, and date respectively. We will also include created_at and updated_at fields.

# db/migrate/20171026042114_create_authors.rb
class CreateAuthors < ActiveRecord::Migration[5.1]
  def change
    create_table :authors do |t|
      t.string :first_name
      t.string :last_name
      t.date :date_of_birth

      t.timestamps
    end
  end
end

We will need to run this migration in order to set up our database with our authors table and move forward with our feature test.

$ rake db:migrate db:test:prepare

Now running our test gives us a new failure:

Screen Shot 2017-10-25 at 10.49.05 PM.png

Read the Failures

Our scaffold created an entire form for us. But, this error is saying that the Date of birth element is not found. This does not make sense to me because looking out our form code I plainly see a date of birth field.

app/views/authors/_form.html.erb

My next troubleshooting step is to open the form in my browser. First, lets start our server:

$ rails s

Next I'll open a browser and navigate to localhost:3000/authors/new

By looking at the form and inspecting the html I see that the the date of birth field is actually a series of three drop downs that each need to be selected. We will have to change the test to reflect this bitter truth. I found this interesting stackoverflow thread about creating a helper method to do just that, but for now I'm happy to re-write my test to read as follows:

# spec/features/author_spec.rb
require 'rails_helper'

feature 'User creates an author' do
  scenario 'they see the author on the page' do
    visit new_author_path

    fill_in "First name", with: 'Frank'
    fill_in "Last name", with: 'Herbert'
    select '1920', from: 'author_date_of_birth_1i'
    select 'October', from: 'author_date_of_birth_2i'
    select '10', from: 'author_date_of_birth_3i'
    click_button 'Create Author'

    expect(page).to have_content 'Frank Herbert'
    expect(page).to have_content 'October 10, 1920'
  end
end

Running the test gives us the following new failure:

Screen Shot 2017-10-25 at 11.03.44 PM.png

Sure enough looking at the drop down, I notice that 1920 is not part of the list. That means we'll need to edit the form and use the start_year option for the date_select method:

<%= form.date_select :date_of_birth, start_year: 1900, id: :author_date_of_birth %>

Running the test again gives us another failure, but it is the last line of the test. We are making progress!

Screen Shot 2017-10-25 at 11.08.29 PM.png

This failure is saying that our test expected to see content "Frank Herbert" on the page after creating a new author, but instead got first name: Frank last name: Herbert. Again, We'll have to just update the test to be a bit more forgiving.

# spec/features/author_spec.rb
require 'rails_helper'

feature 'User creates an author' do
  scenario 'they see the author on the page' do
    visit new_author_path

    fill_in "First name", with: 'Frank'
    fill_in "Last name", with: 'Herbert'
    select '1920', from: 'author_date_of_birth_1i'
    select 'October', from: 'author_date_of_birth_2i'
    select '10', from: 'author_date_of_birth_3i'
    click_button 'Create Author'

    expect(page).to have_content 'Frank'
    expect(page).to have_content 'Herbert'
    expect(page).to have_content '1920-10-10'
  end
end

Now our test passes.

Screen Shot 2017-10-25 at 11.12.32 PM.png

Now to test it ourselves on local development. Let's spin up our server:

$ rails s

Next navigate to localhost:3000/authors/new and complete the form.

Screen Shot 2017-10-25 at 11.14.36 PM.png

Clicking Create Author gets us the authors show page:

Validations

Our create author functionality is tested, but there is still work to be done. The next step is to add validations to our author model to prevent bad records from being created in our database. For example, there probably should not be duplicate Frank Herbert's born 1920-10-10 in the database.

Let's start by adding tests that we validate the presence of each field on the offer model and then uniqueness across all three fields. In other words, we want to allow for multiple authors with first name Frank, and even authors with the same first and last names. But, we want to avoid saving duplicate records in our database by validating for uniqueness across all three fields on the author model.

# spec/models/author_spec.rb
require 'rails_helper'

RSpec.describe Author, type: :model do
  describe "validations" do
    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
    it { should validate_presence_of(:date_of_birth) }

    it "raises error for duplicate records" do
      create :author, first_name: 'Frank', last_name: 'Herbert', date_of_birth: Date.new(1920,10,10)
      author = build :author, first_name: 'Frank', last_name: 'Herbert', date_of_birth: Date.new(1920,10,10)

      expect { author.save! }.to raise_error
    end
  end
end

The first three tests for validations on the presence of our three fields (e.g authors must have a first name, last name and date of birth to be saved to our database) can be implemented as follows:

# app/models/author.rb
class Author < ApplicationRecord
  validates_presence_of :first_name, :last_name, :date_of_birth
end

The last test is a bit tougher. We create an author with Frank, Herbert, and 1920-10-10 as the first name, last name, and date of birth respectively. Then we build an identical record. Build does not save the record to the database, but stores it in memory without running validations. Calling save! on our second author will run validations and so we expect these validations to raise some sort of error indicating that the record must be unique.

According to a number of sources, the best way to implement this type of uniqueness validation is to add an index to the database table. This will guard against the scenario where two users create identical authors at the same time and the database ends up with duplicative records. To add our index we will run:

$ rails g migration add_unique_index_to_authors

Next we will go into the migration file we just created, which can be found in db/migrate/timestamp_add_unique_index_to_authors and write:

# db/migrate/20171027040353_add_unique_index_to_authors.rb
class AddUniqueIndexToAuthors < ActiveRecord::Migration[5.1]
  def change
    add_index :authors, [:first_name, :last_name, :date_of_birth], unique: true 
  end
end

Now we will run this migration so that moving forward the database will ensure the uniqueness of this index column, avoiding the race condition.

$ rake db:migrate db:test:prepare

This will cause our schema.rb file to change and now look like:

# db/schema.rb
ActiveRecord::Schema.define(version: 20171027040353) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"

  create_table "authors", force: :cascade do |t|
    t.string "first_name"
    t.string "last_name"
    t.date "date_of_birth"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["first_name", "last_name", "date_of_birth"], name: "index_authors_on_first_name_and_last_name_and_date_of_birth", unique: true
  end

end

Now let's run our tests and they should all pass!

All the tests are indeed passing (some of these tests were generated by our rails g scaffold command), but we do have a warning. Expecting raise_error without specifying the error type or message is kind of dangerous. We really want to expect an error because we are saving a duplicate record. We could be getting a false positive if some other error is getting triggered by our author.save! call.

# spec/models/author_spec.rb
require 'rails_helper'

RSpec.describe Author, type: :model do
  describe "validations" do
    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
    it { should validate_presence_of(:date_of_birth) }

    it "raises error for duplicate records" do
      create :author, first_name: 'Frank', last_name: 'Herbert', date_of_birth: Date.new(1920,10,10)
      author = build :author, first_name: 'Frank', last_name: 'Herbert', date_of_birth: Date.new(1920,10,10)
      expect { author.save! }.to raise_error ActiveRecord::RecordNotUnique
    end
  end
end

We can be more specific by saying that we expect author.save! to raise an ActiveRecord::RecordNotUnique error. Now when we run our tests they should all pass and we should not see that warning message.

Feature Test the RUD

I think it's worth adding a few more pieces to our feature test. What we can do for readability is define methods within our test file that check create, read, update, and delete functionality. So our test will read "author CRUD using web interface create new author visit author index edit existing author delete existing author."

# spec/features/author_spec.rb
require 'rails_helper'

feature 'author CRUD' do
  scenario 'using web interface' do
    create_new_author
    visit_author_index
    edit_existing_author
    delete_existing_author
  end

  def create_new_author
    visit new_author_path
    fill_in "First name", with: 'Frank'
    fill_in "Last name", with: 'Herbert'
    select '1920', from: 'author_date_of_birth_1i'
    select 'October', from: 'author_date_of_birth_2i'
    select '10', from: 'author_date_of_birth_3i'
    click_button 'Create Author'

    expected_content = %w(Frank Herbert 1920-10-10)

    expect_contents(expected_content)
  end

  def visit_author_index
    # add an additional author to the database so that we
    # can assert that we see multiple authors on the index page
    create :author, first_name: 'Michael', last_name: 'Lewis', date_of_birth: Date.new(1960,10,15)
    visit authors_path

    expected_content = %w(Frank Herbert 1920-10-10 Michael Lewis 1960-10-15)
    # Expect the index table to have three rows, a header row and then one for
    # each author.
    expect(page).to have_css 'tr', count: 3
    expect_contents(expected_content)
  end

  def edit_existing_author
    visit edit_author_path(Author.first)
    fill_in "First name", with: 'JRR'
    fill_in "Last name", with: 'Tolkein'
    select '1892', from: 'author_date_of_birth_1i'
    select 'January', from: 'author_date_of_birth_2i'
    select '3', from: 'author_date_of_birth_3i'
    click_button 'Update Author'

    expected_content = %w(JRR Tolkein 1892-01-03)
    expect_contents(expected_content)
  end

  def delete_existing_author
    visit authors_path
    first(:link, 'Destroy').click
    expect(page).to have_css 'tr', count: 2
  end

  def expect_contents(contents)
    contents.each { |content| expect(page).to have_content content }
  end
end

These additional feature tests should all pass since the scaffold implements full CRUD functionality. 

Conclusion

We are well on our way to an amazing Rails application. Next up, we'll be doing some more CRUD work and create a genre model.

You can start from this point of the live blog journey by cloning / forking this branch of my bookshelf repo.