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

We need a 'needs_splitting' method:

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'

  def needs_splitting
    false
  end
end

testing again should now gives us failing test:

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

1)
'TimeEntry should know that it needs to be split across days' FAILED
expected true, got false
./spec/models/time_entry_spec.rb:40:

Finished in 0.127189 seconds

8 examples, 1 failure, 3 pending
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) 

To pass the test we can simply check that the day on the end Time is greater than the day on the start Time:

  def needs_splitting
    self.start.day < self.end.day
  end

Now we can run the test, passing with flying colors:

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

Finished in 0.079906 seconds

8 examples, 0 failures, 3 pending
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) 

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

The next piece of functionality is a bit more involved. We want a method that tells us whether a TimeEntry instance spans over multiple days, if that is the case then I want to also have a method that can give me a collection of TimeEntry(s) representing all the single day interval. So here's a simple test that I think can accomplish that:

  it "should know how to split a time entry across multiple 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)
    entries = time_entry.split!
    entries.should_not be_empty
    total = 0.0
    entries.inject(0) {|total, e| total += e.total_hours}
    total += time_entry.total_hours
    total.should eql(5.0)
    time_entry.needs_splitting.should_not be_true
  end

In this test I have a start time that is 2 hours before midnight of the current day and an end date that 5 hours ahead which gives us a day crossing range. Then we call the split! method that should return a collection of one or more TimeEntry(s) each containing any hours allocated to a whole or partial day. The split! method should also modify the current TimeEntry to contain only the hours reported on the first day of the range.

In the test we also check that the total number of hours is still the same after the split and that after the split we no longer need to split (e.g. the needs_splitting should return false)

Let's add the skeleton methods for split! and total_hours:

  def split!
    remaining_time_entries = []
  end

  def total_hours
    0.0
  end

Running the test should show a failure:

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

1)
'TimeEntry should know how to split a time entry across multiple days' FAILED
expected empty? to return false, got true
./spec/models/time_entry_spec.rb:48:

Finished in 0.144307 seconds

9 examples, 1 failure, 2 pending
TimeEntry should not have a value higher than 24 hours (Not Yet Implemented)
TimeEntry should be invalid without an associated Project Activity (Not Yet Implemented) 

Let's work out the split! method. Below is my first attempt (somehow I think my Java background might show and I welcome more rubyeske versions of this method):

  def split!
    remaining_time_entries = []
    if needs_splitting
      # save the original end time
      original_end = self.end 
      # first change the current TimeEntry to the end_of_day of the start day
      self.end = self.start.change(:hour => 23, :min => 59, :sec => 59)
      (self.start.day+1..original_end.day).each do |day|
        time_entry = self.clone
        time_entry.start = time_entry.start.change(:mday => day).beginning_of_day
        if day == original_end.day
          time_entry.end = original_end
        else
          time_entry.end = time_entry.start.change(:hour => 23, :min => 59, :sec => 59)
        end
        remaining_time_entries << time_entry
      end   
    end
    remaining_time_entries
  end

The total_hours method is fairly simple:

  # calculate total hours, round to 2 decimal places
  def total_hours
    (((self.end - self.start) / 3600) * 10**2).round.to_f / 10**2
  end

We can run the tests again and see what we have left to build:

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

Finished in 0.15218 seconds

9 examples, 0 failures, 2 pending

Pending:
TimeEntry should not have a value higher than 24 hours (Not Yet Implemented)
TimeEntry should be invalid without an associated Project Activity (Not Yet Implemented)

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

This test if fairly simple to code, we are looking to check that TimeEntry(s) that have been split do not report more than 24 hours.

  it "should not have a value higher than 24 hours" do
    time_entry = create_time_entry
    time_entry.start = 2.hours.ago(Time.today.beginning_of_day)
    time_entry.end = 5.hours.since(4.days.since(time_entry.start))
    entries = time_entry.split!
    entries.should_not be_empty
    entries.each do |entry|
      entry.total_hours.should be <= 24.0
    end
    time_entry.total_hours.should be <= 24.0
  end 

I added a range of time that spans multiple days and then call split! and check that any of the resulting TimeEntry(s) as well as the original TimeEntry do not have more than 24 hours

Let's run the tests again:

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

Finished in 0.129319 seconds

17 examples, 0 failures, 1 pending

Pending:
TimeEntry should be invalid without an associated Project Activity (Not Yet Implemented)

TimeEntry "should be invalid without an associated Project Activity"

The final test on for TimeEntry seems fairly simple:

    time_entry = create_time_entry(:projects_activity => nil)
    time_entry.should_not be_valid
    time_entry.errors.on(:projects_activity).should eql('must have an associated project activity')
    time_entry.projects_activity = @projects_activity
    time_entry.should be_valid

If you got back to installment #2 you might remember that from the initial Tempo domain model we know that "A Project has some Project Activities associated which are a subset of a global list of Activities". Therefore we need to create a few migrations and a few models to complete to get this test to pass.

Activities

Activities are the the global set of activities. Activities can be added to a project (see Project Activities below)

First create the migration:

/> script/generate migration create_activities
      exists  db/migrate
      create  db/migrate/006_create_activities.rb

An Activity needs a name and a description:

class CreateActivities < ActiveRecord::Migration
  def self.up
    create_table :activities do |t|
      t.column :name, :string
      t.column :description, :string
    end
  end

  def self.down
    drop_table :activities
  end
end

Run the migration:

/> rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== CreateActivities: migrating ================================================
-- create_table(:activities)
   -> 0.0030s
== CreateActivities: migrated (0.0032s) =======================================

Project Users

A Project can have Users associated with it. Only users associated with a Project should be able to enter time against the Project:

Let's create the migration:

/> script/generate migration create_project_users
      exists  db/migrate
      create  db/migrate/007_create_project_users.rb

A Project User table associates a Project and a User. We also need a Role in this table since we will have project owners (managers) and project workers:

class CreateProjectUsers < ActiveRecord::Migration
  def self.up
    create_table :projects_users do |t|
      t.column :project_id, :integer, :null => false
      t.column :user_id, :integer, :null => false
      t.column :role_type, :integer, :null => false
    end
  end

  def self.down
    drop_table :projects_users
  end
end

Run the migration:

/> rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== CreateProjectUsers: migrating ==============================================
-- create_table(:projects_users)
   -> 0.0029s
== CreateProjectUsers: migrated (0.0031s) =====================================

Project Activities

Individual Projects will have some Activities that apply. The Project Activities table will maintain those associations:

/> script/generate migration create_project_activities
      exists  db/migrate
      create  db/migrate/008_create_project_activities.rb

In the table we need only the id of the Project and the Activity we are associating:

class CreateProjectActivities < ActiveRecord::Migration
  def self.up
    create_table :projects_activities do |t|
      t.column :project_id, :integer, :null => false
      t.column :activity_id, :integer, :null => false
    end
  end

  def self.down
    drop_table :projects_activities
  end
end

Run the migration:

/> rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== CreateProjectActivities: migrating =========================================
-- create_table(:projects_activities)
   -> 0.1885s
== CreateProjectActivities: migrated (0.1887s) ================================

Finishing the Models

To get the first working Models I will add some ActiveRecord associations. For a Project we have a bunch of has_many associations. You guess the obvious ones, like a:

  • A Project has many Users via the projects_users table
  • A Project has many Activities via the projects_activity table

To set those associations I am using a couple of explicit join models. The join model for Users in a Project:

class ProjectsUser < ActiveRecord::Base
  belongs_to :project
  belongs_to :user
end

and the join model for the Activities that time can be reported against for a particular Project:

class ProjectsActivity < ActiveRecord::Base
  belongs_to :project
  belongs_to :activity
end

Notice that below in the Project class I can also use the :has_many with the :through option to have a list of actual Activity objects available in the model explicitly via the join model projects_activity. Similarly we have Users and specific type of Users by using the :conditions option to filter the project_users join model:

class Project < ActiveRecord::Base
  has_many :projects_users
  has_many :projects_activity
  has_many :activities, :through => :projects_activity, :source => :activity
  has_many :users, :through => :projects_users, :source => :user
  has_many :managers, :through => :projects_users, :source => :user,
           :conditions => "projects_users.role_type = 2"
  has_many :workers, :through => :projects_users, :source => :user,
           :conditions => "projects_users.role_type = 1"
end

Update:

A reader pointed out that at this point the last test for TimeEntry; "should be invalid without an associated Project Activity" was not passing. That's not surprising since I forgot to post the code for the validations in TimeEntry.

Here's the current state of TimeEntry:

class TimeEntry < ActiveRecord::Base
  belongs_to :user
  belongs_to :project
  belongs_to :projects_activity

  attr_reader :total_hours, :date

  alias :total_hours? :total_hours

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

  def needs_splitting
    self.start.day < self.end.day
  end

  def split!
    remaining_time_entries = []
    if needs_splitting
      # save the original end time
      original_end = self.end 
      # first change the current TimeEntry to the end_of_day of the start day
      self.end = self.start.change(:hour => 23, :min => 59, :sec => 59)
      (self.start.day+1..original_end.day).each do |day|
        time_entry = self.clone
        time_entry.start = time_entry.start.change(:mday => day).beginning_of_day
        if day == original_end.day
          time_entry.end = original_end
        else
          time_entry.end = time_entry.start.change(:hour => 23, :min => 59, :sec => 59)
        end
        remaining_time_entries << time_entry
      end   
    end
    remaining_time_entries
  end

  # calculate total hours, round to 2 decimal places
  def total_hours
    if (self.start != nil) && (self.end != nil)
      (((self.end - self.start) / 3600) * 10**2).round.to_f / 10**2
    else
      0.0 
    end
  end

  def year
    self.start.year
  end

  def date
    "#{Time::RFC2822_DAY_NAME[self.start.wday]}, #{Time::RFC2822_MONTH_NAME[self.start.month-1]} #{self.start.day} #{self.start.year}"
  end

  def to_s
    "\"#{self.comment}\" on #{self.date} for #{self.total_hours} hours"
  end
end

Now you can run the test again and we should now have a clean set of tests for TimeEntry. In the next installment we will tackle testing and creating the application controllers and finally get something that we can show our besides tests on the command line.

Posted in ,  | 2 comments | no trackbacks

Building Tempo with Rails, Part II

Posted by Brian Sam-Bodden Wed, 24 Oct 2007 23:00:00 GMT

Rails

Welcome back to part II of this series. Like I mentioned at the end of the first post; now we start the real work.

BDD, TDD and DDD

The sentence below summarizes what our development strategy will be:

"We are going to flesh the domain (DDD) with behaviours using (BDD) that we will express as tests (TDD) before writing any code"

We are going to be using RSpec, a Ruby XUnit framework that facilitates the writing of tests that test behavior rather than state, in the spirit of Behavior-Driven Development (BDD). With RSpec we will write the tests that will guide the development of our application. We will try to stick with Test-Driven Development (TDD) as closely as possible. RSpec can be thought of as a domain specific language for behavior testing via expectations.

If you have been using Test::Unit and you want to use RSpec you don't have to chose one over the other. Although if you have to make a choice, RSpec will give you more readable tests.

If you want to get some background on the ideas behind BDD take a peek at

Installing RSpec

As we did with Restful Authentication plugin we'll use Piston to install RSpec:

/> piston import svn://rubyforge.org/var/svn/rspec/tags/CURRENT/rspec 
   vendor/plugins/rspec
Exported r2563 from 'svn://rubyforge.org/var/svn/rspec/tags/CURRENT/rspec' 
to 'vendor/plugins/rspec'

Installing RSpec On Rails

An RSpec plugin that integrates with Rails exist which gives you the ability to create specs (read behavior tests) for your models, views, controllers and helpers. To install the RSpec On Rails plugin we again use Piston:

/> piston import svn://rubyforge.org/var/svn/rspec/tags/CURRENT/rspec_on_rails 
   vendor/plugins/rspec_on_rails
Exported r2563 from 'svn://rubyforge.org/var/svn/rspec/tags/CURRENT/rspec_on_rails' 
to 'vendor/plugins/rspec_on_rails'

To create the necessary RSpec directories and bootstrap code you need to run the rspec rake task:

/> script/generate rspec
      exists  spec
      create  spec/spec_helper.rb
      create  spec/spec.opts
      create  previous_failures.txt
      create  script/spec_server
      create  script/spec 

Building Tempo's Domain

Now we are ready to tackle the domain of the application

Initial Domain Model

I know it is a cliché but I actually modelled the domain on a paper towel which I later scanned (Jim Weirich is much more classy and uses fine napkins). Below is my first pass at the nouns that immediately came to mind when thinking about tracking time in the context of a project at a consulting firm:

Tempo Domain Model

Based on the noun list above I created a simple UML-like diagram (in later issues of this blog I'll create something nicer):

Tempo Domain Model

The list below spells out what my thinking was when creating this model:

  1. A User is associated with a Person *
  2. A User can be part of zero or more Projects
  3. A Project belongs to a Client *
  4. A Project has some Project Activities associated which are a subset of a global list of Activities
  5. A TimeEntry represents time entered against a project by a User in the context of a particular Activity

(1*) my thinking here is that I would have non-user entities in the system therefore I wanted to separate the Person/People (3*) don't know yet if a Client is a Person, User or other Entity

Person

Based on my napkin domain model a User has an associated Person. Let's use the Rspect generate script to create a model class for Person with its associated specs:

/> script/generate rspec_model Person
      exists  app/models/
      create  spec/models/
      create  spec/fixtures/
      create  app/models/person.rb
      create  spec/fixtures/people.yml
      create  spec/models/person_spec.rb
      exists  db/migrate
      create  db/migrate/002_create_people.rb

Just like the normal Rails "generate script" we end up with a Person class in person.rb:

class Person < ActiveRecord::Base
end

But we also get the skeleton code for a Person spec in person_spec.rb:

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

describe Person do
  before(:each) do
    @person = Person.new
  end

  it "should be valid" do
    @person.should be_valid
  end
end

The basic spec skeleton "describe"s the behavioral contract that a class should abide to. The before method gets executed before each of the "it" or specification methods. In the case about we have the simplest specification method for a Rails model object. In this case it will call the validation method exposed by ActiveRecord.

Before we move forward we need to flesh out the Person class via the ActiveRecord migration. I'm gonna add just the basic fields that we would expect in a Person object (First Name, Last Name and Middle Initial):

class CreatePeople < ActiveRecord::Migration
  def self.up
    create_table :people do |t|
      t.column :first_name, :string
      t.column :last_name, :string
      t.column :middle_initial, :string
    end
  end

  def self.down
    drop_table :people
  end
end

Let's run the migration...

/> rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== CreatePeople: migrating ====================================================
-- create_table(:people)
   -> 0.0033s
== CreatePeople: migrated (0.0035s) ===========================================

During development I might create a ton of little migrations, you can later glue some of those together and refactor your migrations. In the Person class I want to make the first and last names mandatory but the middle initial optional. Now we can enhance the spec to test for the presence of the required fields.

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

describe "A person (in general)" do
  before do
    @user = Person.new
  end

  it "should be invalid without a first and last name" do
    @user.should_not be_valid
    @user.first_name = 'Chunky'
    @user.last_name = 'Bacon'
    @user.should be_valid
  end
end

The spec above tell us that a "user should be invalid without a first and last name". I check that right after we call the new method the object should not be valid and that after we set the first and last name it should.

Let's run the test and see what happens:

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

1)
'A person (in general) should be invalid without a first and last name' FAILED
expected valid? to return false, got true
./spec/models/person_spec.rb:19:

Finished in 0.100052 seconds

1 example, 1 failure

Ok, the test failed... and that's good. Now let's write enough code to pass the test. To do this we can just piggy back on the power of ActiveRecord and add validations to the Person model:

class Person < ActiveRecord::Base
  validates_presence_of :first_name, :last_name 
end

There is sometimes a fine line between testing your code and inadvertently testing a framework. In this case I think that we are testing that our code correctly uses the validation features of ActiveRecord but some might argue this.

Let's run the tests again:

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

Finished in 0.046277 seconds

1 example, 0 failures

User

Great, so we have our first pseudo real spec. The next thing we want to do is associate the Person object to the User object created by the Restful Authentication generator.

From the napkin model we know that a User should have a Person. Let's write a spec that tests that condition:

module UserSpecHelperMethods
  def create_user(options = {})
    User.create({ :login => 'cbacon', 
                  :email => 'chunky@bacon.com', 
                  :password => 'uhmmbacon', 
                  :password_confirmation => 'uhmmbacon'
                }.merge(options))
  end
end

describe User do

  include UserSpecHelperMethods

  before do
    @user = create_user
    @person = Person.new
    @person.first_name = 'Chunky'
    @person.last_name = 'Bacon'
  end

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

Notice that I added a module to my spec class with a helper method that creates a User object with certain fields populated. In the "before" method I create a User and a Person (a valid Person). Finally in the method that tests that a User "should be invalid without an associated Person" I follow the pattern we used before. I test that the object is invalid and then I set the Person object onto the User object and test that they User is now valid. I added a little bit of code temporarily to inspect the errors hash so that I could grab the exact error String and test for it:

@user.errors.each do |error|
  puts error
end

With new test in place we can run the tests again:

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

1)
NoMethodError in 'User should be invalid without an associated Person'
undefined method `person=' for #
./spec/models/user_spec.rb:17:

Finished in 0.054722 seconds

2 examples, 1 failure

As expected, it fails. Let's write just enough code to pass the test, let's create a relationship between User and Person

The ActiveRecord relationship I'm interested in is a has_one, as the code snippet from user.rb below shows:

has_one :person, :dependent => :destroy

The :dependent flag would make sure that the deletes cascade so we are implying that a Person that gets associated with a User should not be left orphaned.

We also need to create a Rails Migration to add the relationship in the database:

/> script/generate migration add_person_to_user
      exists  db/migrate
      create  db/migrate/003_add_person_to_user.rb

Modify the migration to add a column for the person_id:

class AddPersonToUser < ActiveRecord::Migration
  def self.up
    add_column :users, :person_id, :integer
  end

  def self.down
    remove_column :users, :person_id
  end
end

... and of course, run the migration:

/> rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== AddPersonToUser: migrating =================================================
-- add_column(:users, :person_id, :integer)
   -> 0.6555s
== AddPersonToUser: migrated (0.6556s) ========================================

... and test again

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

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

Finished in 0.058424 seconds

2 examples, 1 failure

Add the validation in user.rb to pass the test:

  validates_presence_of :person, 
                        :message => 'must have an associated person'

... and test again:

/> rake spec
(in /Users/bsbodden/Documents/projects/tempo)
../spec/models/user_spec.rb:26: warning: multiple values for a block parameter (2 for 1)
        from ...
person
must have an associated person
.

Finished in 0.059858 seconds

2 examples, 0 failures

So far we have learned the basics of testing your model using RSpec. In the next installment we will pick a critical piece of functionality and build it using TDD/BDD/DDD.

Posted in ,  | no comments | no trackbacks

Building Tempo with Rails, Part I

Posted by Brian Sam-Bodden Sat, 20 Oct 2007 18:57:00 GMT

Rails

This is the first blog entry in a series about the process of going from idea to running software, while following all of the practices that we praise and push at our clients.

Tempo

At Integrallis we have been using an open source PHP-based Time Tracking Tool for a few years now. Don't get me wrong, we are thankful that this tool was out there to help us run our business but it's missing several features that we consider key, we don't have anybody on staff that knows PHP and the app is just plain ugly ;-) So we decided to write our own, Yeah, yeah we are suffering from the not invented here syndrome but in my defense I thought it would make for a nice series of blog entries/tutorials on how we are approaching the building of this application using Ruby on Rails. So let's start by describing in just a few paragraphs what is that we are looking to build.

"Tempo is a project based time tracking web application targeted primarily at small consulting firms and independent consultants"

To track the development of Tempo we are using the agile project management tool Savila created by our friends at Caimito Technologies. We are starting with a few simple user stories that should take us to the basic functionality required.

The initial user stories revolve around the basics of a time tracking system, basic project/tasks management and the authentication system.

Getting Started

Before we get to the user stories let's make sure that you have everything that you need to get started. We are using Rails version 1.2.3, Ruby 1.8.6 and MySQL 5.0.24.

There are countless Rails tutorials on the web. If you've never worked with Rails I suggest you go through one the top RoR tutorials

I have created a skeleton rails application (hopefully you know how to do that by know but just in case you don't, simply type:

/>rails tempo
We can use the about script to see what we have so far...
/> script/about


About your application's environment
Ruby version                 1.8.6 (i686-darwin8.9.1)
RubyGems version             0.9.2
Rails version                1.2.3
Active Record version        1.15.3
Action Pack version          1.13.3
Action Web Service version   1.2.3
Action Mailer version        1.3.3
Active Support version       1.4.2
Application root             /Users/bsbodden/Documents/projects/tempo
Environment                  development
Database adapter             mysql
Database schema version      4

At this point you should be able to get your skeleton application running.

Initial User Stories

The table and the Savila screenshot below show the user stories that we will be tackling on "Sprint #1" of the Tempo development. The duration of the Spring should be around 2 weeks (I'm building Tempo on my spare time ;-)

Story IdShort DescriptionStatusComplexityBusiness Value
TMPO-4Login into the siteOPEN10 pointsBusiness Critical
TMPO-15Site Admin adds global ActivitiesOPEN20 pointsBusiness Critical
TMPO-19Site Administrator Creates a Project Owner AccountOPEN20 pointsBusiness Critical
TMPO-21Site Administrator creates a User AccountOPEN20 pointsBusiness Critical
TMPO-25Site Admin creates a ProjectOPEN20 pointsBusiness Critical
TMPO-27Site Administrator assigns a User as Project Owner for a given ProjectOPEN20 pointsBusiness Critical
TMPO-29User enters TimeOPEN40 pointsBusiness Critical

savila screenshot - sprint 1 stories

Managing Plug-ins with Piston

Before we start downloading and installing plugins I decided that I needed a better way to manage the plugins in my application. To accomplish this I installed Piston. Piston enables you to better manage the vendor branch. It does a better and simpler job that using svn:externals, and you can "install" plugins into your vendor/plugins directory and lock them to a certain version.

Install Piston

Install the piston gem by using the following command:

gem install -y piston

Bulk updating Gem source index for: http://gems.rubyforge.org
Successfully installed piston-1.3.3

Restful Authentication

Since a lot of the initial stories revolve around a User, a good starting point is to get an authentication system going. Since I want to also make Tempo a Restful Rails application. I will based my authentication system on the Restful Authentication plugin, which is a restful version of the familiar actsas_authenticated. Let's use Piston to import the restfulauthentication plugin and create our user model and sessions controller. I find that very often in web-based application the User model is a good starting point from where to flesh out the rest of the system.

Install Restful Authentication Plugin

/> piston import http://svn.techno-weenie.net/projects/plugins/restful_authentication 
   vendor/plugins/restful_authentication

Exported r2983 from 'http://svn.techno-weenie.net/projects/plugins/restful_authentication' 
to 'vendor/plugins/restful_authentication'

Generate User model and the Sessions controller

/> script/generate authenticated user sessions

----------------------------------------------------------------------
Don't forget to:

  - add restful routes in config/routes.rb
    map.resources :users
    map.resource  :session

 Rails 1.2.3 may need a :controller option for the singular resource:
  - map.resource :session, :controller => 'sessions'


Try these for some familiar login URLs if you like:

  map.signup '/signup', :controller => 'users', :action => 'new'
  map.login  '/login', :controller => 'sessions', :action => 'new'
  map.logout '/logout', :controller => 'sessions', :action => 'destroy'

----------------------------------------------------------------------

      exists  app/models/
      exists  app/controllers/
      exists  app/controllers/
      exists  app/helpers/
      create  app/views/sessions
      exists  test/functional/
      exists  app/controllers/
      exists  app/helpers/
      create  app/views/users
      exists  test/functional/
      exists  test/unit/
      create  app/models/user.rb
      create  app/controllers/sessions_controller.rb
      create  app/controllers/users_controller.rb
      create  lib/authenticated_system.rb
      create  lib/authenticated_test_helper.rb
      create  test/functional/sessions_controller_test.rb
      create  test/functional/users_controller_test.rb
      create  app/helpers/sessions_helper.rb
      create  app/helpers/users_helper.rb
      create  test/unit/user_test.rb
      create  test/fixtures/users.yml
      create  app/views/sessions/new.rhtml
      create  app/views/users/new.rhtml
      create  db/migrate
      create  db/migrate/001_create_users.rb
/> 

As the plugin clearly suggests I added the following code to my routes.rb file in /config

  # Restful Authentication routes
  map.resources :users
  map.resource  :session, :controller => 'sessions'

  # Map to familiar URLs
  map.signup '/signup', :controller => 'users', :action => 'new'
  map.login  '/login', :controller => 'sessions', :action => 'new'
  map.logout '/logout', :controller => 'sessions', :action => 'destroy'

Ok now we have basic authentication and some tests to prove that. We could go ahead and add out changes to the User model but first let's run the migration in development and make sure that all the tests that the plugin generator created pass.

If you haven't done it yet, go ahead and configure the database and create the mysql instance for tempo_development

Running our first migration for Tempo

>rake db:migrate

/> rake db:migrate
(in /Users/bsbodden/Documents/projects/tempo)
== CreateUsers: migrating =====================================================
-- create_table("users", {:force=>true})
   -> 0.0591s
== CreateUsers: migrated (0.0593s) ============================================

Running the existing tests

/> rake
(in /Users/bsbodden/Documents/projects/tempo)

Started
..............
Finished in 0.219704 seconds.

14 tests, 26 assertions, 0 failures, 0 errors

Yay! You can test the app (assuming that you've started the server) by pointing your browser to http://localhost:port /signup /login /logout

Now the real work starts, in the next installment of this series we will tackle the development of the domain using DDD (Domain Driven Development) and TDD (Test Driven Development).

Posted in ,  | 1 comment | no trackbacks

Categories

Archives

Syndicate