4

Consider the following:

ScheduledSession ------> Applicant <------ ApplicantSignup

Points to note:

  1. A ScheduledSession will exist in the system at all times; think of this as a class or course.
  2. The intent here is to validate the ApplicantSignup model against an attribute on ScheduledSession during signups_controller#create

Associations

class ScheduledSession < ActiveRecord::Base
  has_many :applicants, :dependent => :destroy
  has_many :applicant_signups, :through => :applicants
  #...
end

class ApplicantSignup < ActiveRecord::Base
  has_many :applicants, :dependent => :destroy
  has_many :scheduled_sessions, :through => :applicants
  #...
end

class Applicant < ActiveRecord::Base
  belongs_to :scheduled_session
  belongs_to :applicant_signup

  # TODO: enforce validations for presence
  # and uniqueness constraints etc.
  #...
end

SignupsController

Resources are RESTful, i.e. the #create action will have a path that's similar to /scheduled_sessions/:id/signups/new

def new
  @session = ScheduledSession.find(params[:scheduled_session_id])
  @signup = @session.signups.new
end

def create
  @session = ScheduledSession.find(params[:scheduled_session_id])
  @session.duration = (@session.end.to_time - @session.start.to_time).to_i
  @signup = ApplicantSignup.new(params[:signup].merge(:sessions => [@session]))

  if @signup.save
   # ...
  else
    render :new
  end
end

You'll notice I'm setting a virtual attribute above @session.duration to prevent Session from being considered invalid. The real 'magic' if you will happens in @signup = ApplicantSignup.new(params[:signup].merge(:sessions => [@session])) which now means that in the model I can select from self.scheduled_sessions and access the ScheduledSession this ApplicantSignup is being built against, even though at this very point in time, there is no record present in the join table.

Model validations for example look like

def ensure_session_is_upcoming
  errors[:base] << "Cannot signup for an expired session" unless self.scheduled_sessions.select { |r| r.upcoming? }.size > 0
end

def ensure_published_session
  errors[:base] << "Cannot signup for an unpublished session" if self.scheduled_sessions.any? { |r| r.published == false }
end

def validate_allowed_age
  # raise StandardError, self.scheduled_sessions.inspect
  if self.scheduled_sessions.select { |r| r.allowed_age == "adults" }.size > 0
    errors.add(:dob_year) unless (dob_year.to_i >= Time.now.strftime('%Y').to_i-85 && dob_year.to_i <= Time.now.strftime('%Y').to_i-18)
    # elsif ... == "children"
  end
end  

The above works quite well in development and the validations work as expected — but how does one test with with Factory Girl? I want unit tests to guarantee the business logic I've implemented after all — sure, this is after the fact but is still one way of going about TDD.

You'll notice I've got a commented out raise StandardError, self.scheduled_sessions.inspect in the last validation above — this returns [] for self.scheduled_sessions which indicates that my Factory setup is just not right.

One of Many Attempts =)

it "should be able to signup to a session" do
  scheduled_session = Factory.build(:scheduled_session)
  applicant_signup = Factory.build(:applicant_signup)
  applicant = Factory.create(:applicant, :scheduled_session => scheduled_session, :applicant_signup => applicant_signup)
  applicant_signup.should be_valid
end

it "should be able to signup to a session for adults if between 18 and 85 years" do
  scheduled_session = Factory.build(:scheduled_session)
  applicant_signup = Factory.build(:applicant_signup)
  applicant_signup.dob_year = 1983 # 28-years old
  applicant = Factory.create(:applicant, :scheduled_session => scheduled_session, :applicant_signup => applicant_signup)
  applicant_signup.should have(0).error_on(:dob_year)
end

The first one passes, but I honestly do not believe it's properly validating the applicant_signup model; the fact that self.scheduled_sessions is returning [] simply means that the above just isn't right.

It's quite possible that I'm trying to test something outside the scope of Factory Girl, or is there a far better approach to tackling this? Appreciate all comments, advice and constructive criticism!

Updates:

  • Not sure what this is called but this is the approach taken at least with regards to how it's implemented at the controller level
  • I need to consider ignoring Factory Girl for the association aspect at least and attempt to return the scheduled_session by mocking scheduled_sessions on the applicant_signup model.

Factories

FactoryGirl.define do  
  factory :signup do
    title "Mr."
    first_name "Franklin"
    middle_name "Delano"
    last_name "Roosevelt"
    sequence(:civil_id) {"#{'%012d' %  Random.new.rand((10 ** 11)...(10 ** 12))}"}    
    sequence(:email) {|n| "person#{n}@#{(1..100).to_a.sample}example.com" }
    gender "male"
    dob_year "1980"
    sequence(:phone_number) { |n| "#{'%08d' %  Random.new.rand((10 ** 7)...(10 ** 8))}" }
    address_line1 "some road"
    address_line2 "near a pile of sand"
    occupation "code ninja"
    work_place "Dharma Initiative"
  end

  factory :session do
    title "Example title"
    start DateTime.civil_from_format(:local,2011,12,27,16,0,0)
    duration 90
    language "Arabic"
    slides_language "Arabic & English"
    venue "Main Room"
    audience "Diabetic Adults"
    allowed_age "adults"
    allowed_gender "both"
    capacity 15
    published true
    after_build do |session|
      # signups will be assigned manually on a per test basis
      # session.signups << FactoryGirl.build(:signup, :session => session)
    end  
  end

  factory :applicant do
    association :session
    association :signup
  end

  #...
end 
Michael De Silva
  • 3,808
  • 1
  • 20
  • 24
  • You should provide information on your factories for Models. If none present, try to create one that uses `after_build` callback or `association` type of declaration. As another approach you could use Rspecs `stub_model`. Also, if you test `ApplicantSignup`, you should init it and dont test the creation of the `Applicant`. For example: `applicant_signup = Factory.build(:applicant_signup); applicant_signup.should_receive(:scheduled_sessions).and_return{[scheduled_session]};`. So there will be less DB access, and you will test `ApplicantSignup`, not `Applicant`. – Mark Huk Nov 14 '11 at 11:33
  • Thanks Mark, please see my update as requested. Will fully digest your comment later this evening! I was having the above issues with the `association` declaration as seen above. I stopped using the `after_build` callback in favour of setting things up in the test itself. Ha! `applicant_signup.should_receive(:scheduled_sessions).and_return{[scheduled_sessi‌​on]};` - should do the trick, exactly similar to the idea of stubbing `:scheduled_sessions` out and returning an array - that should work! – Michael De Silva Nov 14 '11 at 12:26
  • Interestingly I'm getting `(#).scheduled_sessions(any args)` -- `expected: 1 time; received: 10 times`. The good news is this works though: `StandardError: [#]` – Michael De Silva Nov 16 '11 at 09:35
  • Success! `stub!(..)` works, which was my untested idea above. Thanks so much Mark! Post your comment as an answer and the bounty is yours. – Michael De Silva Nov 16 '11 at 09:37

2 Answers2

0

My earlier assumption was correct, with on small change:

I need to consider ignoring Factory Girl for the association aspect at least and attempt to return the scheduled_session by stubbing scheduled_sessions on the applicant_signup model.

making my tests quite simply:

it "should be able to applicant_signup to a scheduled_session" do
  scheduled_session = Factory(:scheduled_session)
  applicant_signup = Factory.build(:applicant_signup)
  applicant_signup.stub!(:scheduled_sessions).and_return{[scheduled_session]}
  applicant_signup.should be_valid
end

it "should be able to applicant_signup to a scheduled_session for adults if between 18 and 85 years" do
  scheduled_session = Factory(:scheduled_session)
  applicant_signup = Factory.build(:applicant_signup)
  applicant_signup.dob_year = 1983 # 28-years old
  applicant_signup.stub!(:scheduled_sessions).and_return{[scheduled_session]}
  applicant_signup.should have(0).error_on(:dob_year)
  applicant_signup.should be_valid
end

and this test in particular required a similar approach:

it "should not be able to applicant_signup if the scheduled_session capacity has been met" do
  scheduled_session = Factory.build(:scheduled_session, :capacity => 3)
  scheduled_session.stub_chain(:applicant_signups, :count).and_return(3)    
  applicant_signup = Factory.build(:applicant_signup)
  applicant_signup.stub!(:scheduled_sessions).and_return{[scheduled_session]}
  applicant_signup.should_not be_valid
end

...and success — ignore the testing duration as spork causes false reporting of this.

Finished in 2253.64 seconds
32 examples, 0 failures, 3 pending
Done.
Michael De Silva
  • 3,808
  • 1
  • 20
  • 24
  • You should feel the difference between `stub` and `should_receive` as latter is more useful as it also fails if the method wasn't called or if it was called wrong number of times: `.should_receive(:method).once.and_return()`. Notice `once` specified. Also on that place could be 'never', so it will test that `:method` **not** called. – Mark Huk Nov 16 '11 at 11:16
  • Off topic, but, 2253 seconds on 32 specs? I advice to refactor your specs to be more robust. – Mark Huk Nov 16 '11 at 11:18
  • Mark, _ignore the testing duration as spork causes false reporting of this._. – tbuehlmann Nov 16 '11 at 11:26
  • Mark, stub and should_receive fulfil different purposes. A should_receive is an explicit expectation that marks the method call as an essential part of the behaviour you are currently exploring. A stub marks out the method as not mattering. – workmad3 Nov 16 '11 at 13:23
  • @workmad3 I agree, but in this case specs check the behavior of the validation, and from that point we could expect that `scheduled_sessions` should be called. – Mark Huk Nov 16 '11 at 14:14
  • @MarkGuk that's true but I don't believe the number of times the method call is being made matters considering that the count represents calls from across all the validations; `at_least(1)` therefore could easily come from a different validation. `Also you can check the required count of call from your code and write this number as expectation` Sure, but this would also require updating the count each time `scheduled_sessions` is called on the instantiated class instance (object). `stub!` therefore makes more sense. – Michael De Silva Nov 16 '11 at 16:01
0

As another approach you could use Rspecs stub_model.

Also, if you test ApplicantSignup, you should init it and not test the creation of the Applicant. Eg:

applicant_signup = Factory.build(:applicant_signup);

applicant_signup.should_receive(:scheduled_sessions)
                           .and_return{[scheduled_sessi‌​on]};

So there will be less DB access and you will test ApplicantSignup, not Applicant.

Khaled.K
  • 5,828
  • 1
  • 33
  • 51
Mark Huk
  • 2,379
  • 21
  • 28
  • `should_receive` causes a failure as it gets called 10 times, instead of once. It's the reason I just went with stub; any thoughts? – Michael De Silva Nov 16 '11 at 13:20
  • There is nothing wrong, as you wrote several validations methods that call `scheduled_sessions`. As in your question I have spotted 3 calls. So I propose for you to use `stub!` or user `at_least(1)` after `should_validate` to allow any times of calls, but require the one call. Also you can check the required count of call from your code and write this number as expectation. – Mark Huk Nov 16 '11 at 14:21
  • I've got a lot more validations, that's why each one is being counted. That makes complete sense - validation triggers *all* the validations, not _just the one I'm testing_! – Michael De Silva Nov 16 '11 at 15:55