Fake date data with intervals for RSpec testing

Viewed 659

I'm using Faker and Factory_bot Gems to generate some fake data for my RSpec tests, and I need to generate an interval of 1 hour for each of them, for example:

The Appointment model has a start_date and end_date, and they should have one hour of difference. For example:

start_date: '2020-10-20 19:51:00' end_date: '2020-10-20 20:51:00'

this is my current Factory:

  factory :appointment do
    start_date { Faker::Date.between(from: 2.year.ago, to: Date.today) }
    end_date { Faker::Date.between(from: 2.year.ago, to: Date.today) }
    user_id nil
    therapist_id nil
  end

I want to know how can I store the first generated fake data and add an hour to it.

1 Answers

First some FactoryBot and Rails tips.

Do not use Date.today in Rails, it is not aware of time zones. Use Time.zone.today. And these are Times, not Dates, so Time.current is more appropriate. Finally, unless all your appointments are in the past, use 2.years.since.

The convention for timestamps is to end them with _at. start_at and end_at. This also avoids confusion about start_date which is a Time, not a Date.


We can take advantage of ActiveSupport::Duration and its Numeric extensions to add to the start_date. end_date { start_date + 1.hour }.

Rather than hard coding the assumptions for a specific test into the factory, we can use a trait to make the assumptions explicit.

factory :appointment do
  # These are the normal conditions.
  # end_at will be 15 to 180 minutes after start_at.
  start_at { Faker::Time.between(from: 2.years.ago, to: 2.years.since) }
  end_at { start_at + rand(15..180).minutes }

  # This is a specific trait putting end_at an hour after start_at.
  trait :in_one_hour do
    end_at { start_at + 1.hour }
  end
end

# An appointment of 1 hour which started yesterday
appointment = build(:appointment, :in_one_hour, start_at: 1.day.ago)

We can make this better. What if we want a different duration? Instead of a trait, use a transient attribute. This lets you send an attribute to the factory which is not an attribute of the object. Like duration.

factory :appointment do
  transient do
    duration { rand(15..180).minutes }
  end

  start_at { Faker::Time.between(from: 2.years.ago, to: 2.years.since) }
  end_at { start_at + duration }
end

# An appointment with a random but reasonable duration.
p build(:appointment)

# An appointment with a duration of exactly 1 hour.
p build(:appointment, duration: 1.hour)

# An appointment lasting 30 minutes starting yesterday.
p build(:appointment, duration: 30.minutes, start_at: 1.day.ago)

There's a problem. What if the caller changes the end_at? Then the start_at should be based on the end_at. But if they set the start_at the end_at needs to be based on the start_at. That leads to circular definitions.

factory :appointment do
  transient do
    duration { rand(15..180).minutes }
  end

  # Circular
  start_at { end_at + duration }
  end_at { start_at - duration }
end

We need to use a callback to avoid the circular dependency.

factory :appointment do
  transient do
    duration { rand(15..180).minutes }
  end
      
  after(:build) do |appointment, evaluator|
    case
    when appointment.start_at && appointment.end_at
      # The user set both, leave them be.
    when appointment.start_at
      # The user set only the start_at.
      appointment.end_at ||= appointment.start_at + evaluator.duration
    when appointment.end_at
      # The user set only the end_at.
      appointment.start_at ||= appointment.end_at - evaluator.duration
    else
      # The user set neither.
      appointment.start_at = Faker::Time.between(from: 2.years.ago, to: 2.years.since)
      appointment.end_at = appointment.start_at + evaluator.duration
    end
  end
end

p build(:appointment)
p build(:appointment, duration: 1.hour, start_at: 1.year.ago)
p build(:appointment, duration: 1.hour, end_at: 1.year.since)
p build(:appointment, start_at: 1.year.ago, end_at: 1.year.since)

Finally, if you're using Postgres, you can merge start_at and end_at into a single range column. This uses Postgres's tstzrange type which Rails will turn into a Range between two Times. This can be much easier to work with than start and end timestamps.

factory :appointment do
  transient do
    duration { rand(15..180).minutes }
  end
        
  timespan do
    start_time = Faker::Time.between(from: 2.years.ago, to: 2.years.since)
    end_time = start_time + duration
    (start_time..end_time)
  end
end

p FactoryBot.build(:appointment)
p FactoryBot.build(:appointment, duration: 1.hour)
p FactoryBot.build(:appointment, timespan: (1.year.ago..Time.current))
Related