Working with Rails

Recommend Brian on Working With Rails


Powered

Building Tempo with Rails, Part III

Posted by Brian Sam-Bodden Wed, 31 Oct 2007 22:00:00 GMT

Rails

Now that we have a basic understanding of BDD with RSpec let's pick a business critical user story and build it from tests outwards. With Tempo being a time tracking system the most crucial story should be a "User enters Time".

TimeEntry

At the core of this story is the model class TimeEntry. In Tempo we want users to be able to enter time by:

  • a) picking a beginning time an an end time for a given date
  • b) picking a beginning date-time and an end date-time

For billing purposes it is usually convenient to split the hours in a range of date-times into chunks of hours per day (for the ranges that span over multiple days). The way I envision these two entry methods are:

  • a) a simple page that defaults to the current date and that allows the user to pick two times for the given day along with other information required (project, activity, etc.)
  • b) a page with two date/time pickers that enables the user to pick a range (or some fancier UI control if I can find one)

In the spirit of TDD let's start with the skeleton RSpec specification for TimeEntry. In the spec/models directory of the application create the Ruby file time_entry_spec.rb:

describe TimeEntry do

  before do
    @time_entry = TimeEntry.new
  end

  it "should be invalid without an associated User" do
  end

  it "should be invalid without an associated Project" do
  end

  it "should be invalid without an associated Project Activity" do
  end

  it "should know that it needs to be split across days" do
  end

  it "should know how to split a time entry across multiple days" do
  end

  it "should not have a value higher than 24 hours" do
  end

end

Notice that I've added an expectation that a given time entry should know that it needs to be split across multiple days and it also should know how to perform this split.

If I run the tests now they should obviously fail. We don't even have a TimeEntry class in place:

rake spec
(in /Users/bsbodden/Documents/projects/tempo)
/.../lib/active_support/dependencies.rb:266:in `load_missing_constant': uninitialized constant TimeEntry (NameError)

Let's get pass that one problem and create the model:

script/generate rspec_model TimeEntry
      exists  app/models/
      exists  spec/models/
      exists  spec/fixtures/
      create  app/models/time_entry.rb
      create  spec/fixtures/time_entries.yml
overwrite spec/models/time_entry_spec.rb? [Ynaqd] n
        skip  spec/models/time_entry_spec.rb
      exists  db/migrate
      create  db/migrate/004_create_time_entries.rb
/> 

Notice that the generate task will try to overwrite the spec we just wrote so answer NO (n) to the overwrite question as shown above. Let's add the basic fields we would expect to find in our TimeEntry class/table:

class CreateTimeEntries < ActiveRecord::Migration
  def self.up
    create_table :time_entries do |t|
      t.column :user_id, :integer
      t.column :project_id, :integer 
      t.column :projects_activity_id, :integer
      t.column :start, :datetime
      t.column :end, :datetime
      t.column :hours, :float
      t.column :comment, :string 
    end
  end

  def self.down
    drop_table :time_entries
  end
end

Run the migration to get the table created:

rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== CreateTimeEntries: migrating ===============================================
-- create_table(:time_entries)
   -> 0.0414s
== CreateTimeEntries: migrated (0.0416s) ======================================

If we run the tests, they all pass now, that's not good, not quite TDD, so let's add some substance and break things as they should:

Let's start with the easy stuff:

  before do
    @time_entry = TimeEntry.new
    @user = User.new
  end

  it "should be invalid without an associated User" do
    @time_entry.should_not be_valid
    @time_entry.errors.on(:user).should eql("must have an associated user")
    @time_entry.user = @user
    @time_entry.should be_valid
  end 

Run the tests again and we now have our first 'expected' failure:

1)
'TimeEntry should be invalid without an associated User' FAILED
expected valid? to return false, got true
./spec/models/time_entry_spec.rb:14:

Finished in 0.06691 seconds

8 examples, 1 failure

As we learned in part II, to pass this test we need a one liner to add an ActiveRecord validation:

class TimeEntry < ActiveRecord::Base
  has_one :user

  validates_presence_of :user, :message => 'must have an associated user'
end

Test again to confirm that the validation test passes:

rake spec
(in /Users/bsbodden/Documents/projects/tempo)
........

Finished in 0.071294 seconds

8 examples, 0 failures

Next one down the line is the test for an associated project:

  before do
    @time_entry = TimeEntry.new
    @user = User.new
    @project = Project.new
  end

  it "should be invalid without an associated Project" do
    @time_entry.should_not be_valid
    @time_entry.errors.on(:project).should eql('must have an associated project')
    @time_entry.project = @project
    @time_entry.should be_valid
  end

Running the test again should confirm that we do not have a Project model yet:

rake spec
(in /Users/bsbodden/Documents/projects/tempo)
..FFFFFF

1)
NameError in 'TimeEntry should not have a value higher than 24 hours if expressed as a single hour value'
uninitialized constant Project
./spec/models/time_entry_spec.rb:12:

2)
NameError in 'TimeEntry should be broken into two or more TimeEntry instances if it date-time range spans over multiple days'
uninitialized constant Project
./spec/models/time_entry_spec.rb:12:

3)
NameError in 'TimeEntry should either consist of a date-time range or a single hour value'
uninitialized constant Project
./spec/models/time_entry_spec.rb:12:

4)
NameError in 'TimeEntry should be invalid without an associated Project Activity'
uninitialized constant Project
./spec/models/time_entry_spec.rb:12:

5)
NameError in 'TimeEntry should be invalid without an associated Project'
uninitialized constant Project
./spec/models/time_entry_spec.rb:12:

6)
NameError in 'TimeEntry should be invalid without an associated User'
uninitialized constant Project
./spec/models/time_entry_spec.rb:12:

Finished in 0.121636 seconds

8 examples, 6 failures

Add the Project model to the mix using the rake task:

script/generate rspec_model Project  
      exists  app/models/
      exists  spec/models/
      exists  spec/fixtures/
      create  app/models/project.rb
      create  spec/fixtures/projects.yml
      create  spec/models/project_spec.rb
      exists  db/migrate
      create  db/migrate/005_create_projects.rb

Modify the migration to add the basic fields expected in a Project:

class CreateProjects < ActiveRecord::Migration
  def self.up
    create_table :projects do |t|
      t.column :name, :string
      t.column :description, :string
      t.column :owner_id, :integer
    end
  end

  def self.down
    drop_table :projects
  end
end

and run the migration:

rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== CreateProjects: migrating ==================================================
-- create_table(:projects)
   -> 0.0377s
== CreateProjects: migrated (0.0380s) =========================================

Let's test again with the Project model in place:

/> rake spec
(in /Users/bsbodden/Documents/projects/tempo)
......F..

1)
'TimeEntry should be invalid without an associated Project' FAILED
expected "must have an associated project", got nil (using .eql?)
./spec/models/time_entry_spec.rb:24:

Finished in 0.081038 seconds

9 examples, 1 failure

Great, now we broke what we wanted to break ;-)

Let's now add the validations for project:

class TimeEntry < ActiveRecord::Base
  has_one :user
  has_one :project

  validates_presence_of :user, :message => 'must have an associated user'
  validates_presence_of :project, :message => 'must have an associated project'
end

Let's test one more time:

rake spec
(in /Users/bsbodden/Documents/projects/tempo)
......FF.

1)
'TimeEntry should be invalid without an associated Project' FAILED
expected valid? to return true, got false
./spec/models/time_entry_spec.rb:26:

2)
'TimeEntry should be invalid without an associated User' FAILED
expected valid? to return true, got false
./spec/models/time_entry_spec.rb:19:

Finished in 0.083707 seconds

9 examples, 2 failures

Let's fix our problems by adding a module with a helper create method for TimeEntries and we'll just nil the associations that we are looking to test:

module TimeEntrySpecHelperMethods
  def create_time_entry(options = {})
    @user = User.new
    @project = Project.new
    TimeEntry.create({ :user => @user, :project => @project }.merge(options))
  end
end

describe TimeEntry do

  include TimeEntrySpecHelperMethods

  it "should be invalid without an associated User" do
    time_entry = create_time_entry(:user => nil)
    time_entry.should_not be_valid
    time_entry.errors.on(:user).should eql('must have an associated user')
    time_entry.user = @user
    time_entry.should be_valid
  end

  it "should be invalid without an associated Project" do
    time_entry = create_time_entry(:project => nil)
    time_entry.should_not be_valid
    time_entry.errors.on(:project).should eql('must have an associated project')
    time_entry.project = @project
    time_entry.should be_valid
  end

Pending Tests in RSpec

What do you know! it always helps to have an expert near by, Joe O'brien from the EdgeCase just stopped by and schooled me on some of the subtleties of RSpec. If you remember I started with a skeleton for a test that was passing and that's not quite following TDD since we should have a test that initially fails. Otherwise, in a team environment there is a big chance that we will "forget" to implement the test.

A very useful trick when you are "scaffolding your tests" like I was is to remove the block, that is the do..end and RSpec will report the tests as pending. So my test now looks like:

require File.dirname(__FILE__) + '/../spec_helper'

module TimeEntrySpecHelperMethods
  def create_time_entry(options = {})
    @user = User.new
    @project = Project.new
    TimeEntry.create({ :user => @user, :project => @project }.merge(options))
  end
end

describe TimeEntry do

  include TimeEntrySpecHelperMethods

  it "should be invalid without an associated User" do
    time_entry = create_time_entry(:user => nil)
    time_entry.should_not be_valid
    time_entry.errors.on(:user).should eql('must have an associated user')
    time_entry.user = @user
    time_entry.should be_valid
  end

  it "should be invalid without an associated Project" do
    time_entry = create_time_entry(:project => nil)
    time_entry.should_not be_valid
    time_entry.errors.on(:project).should eql('must have an associated project')
    time_entry.project = @project
    time_entry.should be_valid
  end

  it "should be invalid without an associated Project Activity"

  it "should know that it needs to be split across days" 

  it "should know how to split a time entry across multiple days" 

  it "should not have a value higher than 24 hours" 

end

Notice the do..ends are gone in the unimplemented tests. When we run the test we now get:

rake spec
(in /Users/bsbodden/Documents/projects/tempo)
...PPPP..

Finished in 0.074545 seconds

9 examples, 0 failures, 4 pending

Pending:
TimeEntry should know that it needs to be split across days (Not Yet Implemented) 
TimeEntry should not have a value higher than 24 hours (Not Yet Implemented)
TimeEntry should know how to split a time entry across multiple days (Not Yet Implemented) 
TimeEntry should be invalid without an associated Project Activity (Not Yet Implemented) 

Now that is cool! Thanks Joe.

Next we'll tackle a more challenging test and associated functionality:

TimeEntry "should know that it needs to be split across days"

This test should check that a TimeEntry can detect that it spans over multiple days. The test is simple, set the start and end dates so that they span over multiple days and check that a method, let's call it "needs_splitting" returns true.

  it "should know that it needs to be split across days" do
    time_entry = create_time_entry
    time_entry.start = 2.hours.ago(Time.today.beginning_of_day)
    time_entry.end = 5.hours.since(time_entry.start)
    time_entry.needs_splitting.should be_true
  end 

Run the test and watch it fail:

rake spec
(in /Users/bsbodden/Documents/projects/tempo)
...PFP..

1)
NoMethodError in 'TimeEntry should know that it needs to be split across days'
undefined method `needs_splitting' for #
./spec/models/time_entry_spec.rb:40:

Finished in 0.088024 seconds

8 examples, 1 failure, 3 pending
Read more...

Posted in ,  | Tags , ,  | 2 comments | no trackbacks

Older posts: 1 2 3 4 5 6

Tags

agile drools gorm Grails Groovy Java orm Rails Ruby Security spring

Categories

Archives

Syndicate

Twitter