DEV Community

loading...
Cover image for Build a Custom RSpec Matcher for Comparing DateTimes

Build a Custom RSpec Matcher for Comparing DateTimes

jbranchaud profile image Josh Branchaud ・4 min read

Comparing timestamps in RSpec tests can be tricky. There are several things you have to get right for your tests to work, and work consistently. For the purpose of this post, I'll set aside the challenges of freezing time or traveling through it. Other posts cover that well.

In this post, we'll look at how to check that a timestamp is what we expect it to be, accounting for DateTime precision, and allowing for the full expressiveness of RSpec's matchers.

I'll first show the custom matcher that we will explore in this post and then we'll dive into the motivation and details behind it.

RSpec::Matchers.define :match_date_within do |date, within|
  match do |subject|
    if within.is_a?(Numeric) && !within.is_a?(ActiveSupport::Duration)
      within = within.seconds
    end

    lower_bound = date - (within / 2.0)
    upper_bound = date + (within / 2.0)

    DateTime.parse(subject).between?(
      lower_bound,
      upper_bound
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

RSpec has an expressive matcher system that enables us to write robust and readable tests in Ruby. Custom matchers are a way for us to expand on that expressiveness.

Precision

Let's address the issue of DateTime precision first.

First, I'll create a variable that snapshots a timestamp (ActiveSupport::TimeWithZone) for a day ago.

> one_day_ago = 1.day.ago
=> Sat, 20 Mar 2021 18:36:20.789402000 UTC +00:00
Enter fullscreen mode Exit fullscreen mode

This date is representative of a timestamp value that will get packaged as a string along with other attributes in a JSON response. In a controller or request spec, I'll want to parse this value back into some kind of DateTime object to make comparisons.

The issue is that we've lost some precision at this point.

> DateTime.parse(json["published_at"])
=> Sat, 20 Mar 2021 18:36:20 +0000
> one_day_ago
=> Sat, 20 Mar 2021 18:36:20.789402000 UTC +00:00
Enter fullscreen mode Exit fullscreen mode

The method I prefer for brushing over this blip in precision is to check that the given date matches within a second of the expected date.

There are a series of RSpec matchers that can do this when you're already dealing with two DateTime objects.

expect(DateTime.parse["published_at"])
  .to be_within(1.second).of(one_day_ago)
Enter fullscreen mode Exit fullscreen mode

This is great and the custom RSpec matcher we're creating builds on this idea. This built-in matcher only gets us so far because we cannot use it as a nested matcher unless dates have already been parsed.

Nested Matching

I want to have all of what RSpec offers at my fingertips, that includes nested matching.

Here is an example of what this nested matching could look like:

expect(json).to match(
  # check other attributes as well...
  "published_at" => be_within(1.second).of(one_day_ago)
)
Enter fullscreen mode Exit fullscreen mode

That right there almost works. It'd be incredible if it did because I love the way that reads.

The snag is that when the top-level match grabs the published_at value and applies it to the be_within(...), it is working with a string representation of the timestamp. We first need to parse that into a date object and there is no affordance for that here.

RSpec gives us the tools to make custom matchers so that we can preserve all the expressiveness while adding specialized or even domain-specific matchers.

Our Custom Matcher

The goal is to create a matcher that reads cleanly and is shaped to the expectation we want to set. Here is what that could look like in a test:

expect(json).to match(
  # check other attributes as well...
  "published_at": match_date_within(one_day_ago, 1.second)
)
Enter fullscreen mode Exit fullscreen mode

A chained matcher may be more appropriate here, but I'll leave that as an exercise for the reader.

In spec/support/matchers/match_date_within.rb, we can define a custom matcher that accepts any number of arguments, makes a determination of whether or not the value under test is a match, and returns either true or false. Make sure support files are included in either rails_helper.rb or spec_helper.rb.

RSpec::Matchers.define :match_date_within do |date, within|
  match do |subject|
    # check for a match here...
  end
end
Enter fullscreen mode Exit fullscreen mode

The date and within arguments are the two values passed in the on the right side of the above expectation. The subject is the value under test. The match matcher is responsible for passing that in.

With the skeleton in place, we can add some logic to parse the subject timestamp (remember, it's still a string at this point) and then determine if it is with an acceptable range based on the precision. I think 1 second is an appropriate window, but this second argument leaves that up to the person writing the test.

Let's add the base logic.

RSpec::Matchers.define :match_date_within do |date, within|
  match do |subject|
    lower_bound = date - (within / 2.0)
    upper_bound = date + (within / 2.0)

    DateTime.parse(subject).between?(
      lower_bound,
      upper_bound
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

The #between? method is a fantastic method for checking if a DateTime falls within the range of two other DateTimes. We take the within value, chop it in half, and then subtract and add with the given date to create the lower and upper bounds of the range.

If we pass in 1.second, then half a second will be subtracted from and added to the date.

For some added flexibility, I decided to allow within to either be a duration or a Numeric. If it is a Numeric, it is assumed to be seconds. To support that, I added some type checking and a conversation to duration at the beginning as needed.

RSpec::Matchers.define :match_date_within do |date, within|
  match do |subject|
    if within.is_a?(Numeric) && !within.is_a?(ActiveSupport::Duration)
      within = within.seconds
    end

    lower_bound = date - (within / 2.0)
    upper_bound = date + (within / 2.0)

    DateTime.parse(subject).between?(
      lower_bound,
      upper_bound
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

That's it. As written, this can be used in a spec as demonstrated above. Or in increasingly complex and nested scenarios.

expect(json_body).to match({
  "posts" => match_array([
    hash_including(
      "title" => 'Some title',
      "content" => /A portion of the content/,
      "published_at" => match_date_within(one_day_ago, 1.second)
    )
  ])
})
Enter fullscreen mode Exit fullscreen mode

I write and record on building and testing Rails apps. If you found this article useful, consider joining my newsletter or telling me about it on twitter.

For more details, see the docs for the RSpec::Matchers module.


Cover photo by @whoisbenjamin on Unsplash

Discussion (0)

Forem Open with the Forem app