Build a Custom RSpec Matcher for Comparing DateTimes
Tags: rails, rspec, testing, ruby
Published: 2021-03-21 19:29:33

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.

```ruby
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
```

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.

```ruby
> one_day_ago = 1.day.ago
=> Sat, 20 Mar 2021 18:36:20.789402000 UTC +00:00
```

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.

```ruby
> 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
```

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.

```ruby
expect(DateTime.parse["published_at"])
  .to be_within(1.second).of(one_day_ago)
```

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:

```ruby
expect(json).to match(
  # check other attributes as well...
  "published_at" => be_within(1.second).of(one_day_ago)
)
```

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:

```ruby
expect(json).to match(
  # check other attributes as well...
  "published_at": match_date_within(one_day_ago, 1.second)
)
```

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](https://makandracards.com/makandra/1115-where-to-put-custom-rspec-matchers) in either `rails_helper.rb` or `spec_helper.rb`.

```ruby
RSpec::Matchers.define :match_date_within do |date, within|
  match do |subject|
    # check for a match here...
  end
end
```

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.

```ruby
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
```

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.

```ruby
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
```

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

```ruby
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)
    )
  ])
})
```

I write and record on building and testing Rails apps. If you found this article useful, consider [joining my newsletter](https://crafty-builder-6996.ck.page/e169c61186) or telling me about it on [twitter](https://twitter.com/jbrancha).

For more details, see the docs for the [`RSpec::Matchers` module](https://rubydoc.info/github/rspec/rspec-expectations/RSpec/Matchers).

---

<em>Cover photo by <a href="https://unsplash.com/@whoisbenjamin?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">@whoisbenjamin</a> on <a href="/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a></em>