Проблемы со сравнением времени с RSpec

119

Я использую Ruby on Rails 4 и гем 2.14 rspec-rails. Для своего объекта я хотел бы сравнить текущее время с updated_atатрибутом объекта после выполнения действия контроллера, но у меня проблемы, поскольку спецификация не проходит. То есть, учитывая следующий код спецификации:

it "updates updated_at attribute" do
  Timecop.freeze

  patch :update
  @article.reload
  expect(@article.updated_at).to eq(Time.now)
end

Когда я запускаю указанную выше спецификацию, я получаю следующую ошибку:

Failure/Error: expect(@article.updated_at).to eq(Time.now)

   expected: 2013-12-05 14:42:20 UTC
        got: Thu, 05 Dec 2013 08:42:20 CST -06:00

   (compared using ==)

Как я могу добиться соответствия спецификации?


Примечание : я пробовал также следующее (обратите внимание на utcдобавление):

it "updates updated_at attribute" do
  Timecop.freeze

  patch :update
  @article.reload
  expect(@article.updated_at.utc).to eq(Time.now)
end

но спецификация по-прежнему не проходит (обратите внимание на разницу в значениях "получено"):

Failure/Error: expect(@article.updated_at.utc).to eq(Time.now)

   expected: 2013-12-05 14:42:20 UTC
        got: 2013-12-05 14:42:20 UTC

   (compared using ==)
Backo
источник
Он сравнивает идентификаторы объектов, следовательно, текст из inspect совпадает, но под ним есть два разных объекта Time. Вы можете просто использовать ===, но это может пострадать от пересечения вторых границ. Вероятно, лучше всего найти или написать свой собственный сопоставитель, в котором вы конвертируете в секунды эпохи и допускаете небольшую абсолютную разницу.
Нил Слейтер
Если я понял, что вы относитесь к «пересечению вторых границ», проблемы возникнуть не должно, поскольку я использую гем Timecop, который «замораживает» время.
Backo 05
Ах, я пропустил это, извините. В этом случае просто используйте ===вместо ==- в настоящее время вы сравниваете object_id двух разных объектов Time. Хотя Timecop не заморозит время сервера базы данных. , , поэтому, если ваши временные метки генерируются РСУБД, это не сработает (хотя я думаю, что это не проблема для вас)
Нил Слейтер

Ответы:

156

Объект Ruby Time поддерживает большую точность, чем база данных. Когда значение считывается из базы данных, оно сохраняется только с точностью до микросекунд, тогда как представление в памяти с точностью до наносекунд.

Если вас не волнует разница в миллисекундах, вы можете использовать to_s / to_i с обеих сторон вашего ожидания.

expect(@article.updated_at.utc.to_s).to eq(Time.now.to_s)

или

expect(@article.updated_at.utc.to_i).to eq(Time.now.to_i)

Обратитесь к этому для получения дополнительной информации о том, почему времена разные.

Уша
источник
Как видно из кода вопроса, я использую Timecopдрагоценный камень. Следует ли просто решить проблему, «заморозив» время?
Backo 05
Вы сохраняете время в базе данных и получаете его (@ article.updated_at), что лишает его наносекунды, тогда как Time.now сохраняет наносекунды. Это должно быть ясно из первых нескольких строк моего ответа
usha
3
Принятый ответ почти на год старше, чем ответ ниже - сопоставление be_within - гораздо лучший способ справиться с этим: A) вам не нужен драгоценный камень; Б) работает для любого типа значения (целое число, число с плавающей запятой, дата, время и т. Д.); C) это изначально часть RSpec
notaceo
3
Это «нормальное» решение, но определенно be_withinверное
Франческо Белладонна
Здравствуйте, я использую сравнение даты и времени с ожиданиями задержки постановки задания. expect {FooJob.perform_now}.to have_enqueued_job(FooJob).at(some_time) Я не уверен, что .atсопоставитель примет время, преобразованное в целое число, если .to_iкто-либо столкнулся с этой проблемой?
Сирил Дюшон-Дорис
200

Я считаю использование be_withinсопоставления rspec по умолчанию более элегантным:

expect(@article.updated_at.utc).to be_within(1.second).of Time.now
Oin
источник
2
.000001 с немного туго. Я пробовал даже 0,001, и иногда это терпело неудачу. Думаю, даже 0,1 секунды доказывает, что время идет сейчас. Достаточно хорошо для моих целей.
Smoyth
3
Я думаю, что этот ответ лучше, потому что он выражает цель теста, а не скрывает его за некоторыми несвязанными методами, такими как to_s. Кроме того, to_iи to_sможет нечасто выходить из строя, если время приближается к концу секунды.
B Seven
2
Похоже, что be_withinсопоставление было добавлено в RSpec 2.1.0 7 ноября 2010 г. и с тех пор несколько раз улучшалось. rspec.info/documentation/2.14/rspec-expectations/…
Марк Берри,
1
Я понимаю undefined method 'second' for 1:Fixnum. Мне что-то нужно require?
Дэвид Моулз
3
@DavidMoles .second- это расширение rails: api.rubyonrails.org/classes/Numeric.html
jwadsack
11

Старый пост, но я надеюсь, что он поможет любому, кто войдет сюда за решением. Думаю, проще и надежнее просто создать дату вручную:

it "updates updated_at attribute" do
  freezed_time = Time.utc(2015, 1, 1, 12, 0, 0) #Put here any time you want
  Timecop.freeze(freezed_time) do
    patch :update
    @article.reload
    expect(@article.updated_at).to eq(freezed_time)
  end
end

Это гарантирует, что сохраненная дата является правильной, не to_xбеспокоясь о десятичных дробях.

jBilbo
источник
Убедитесь, что вы разморозили время в конце спецификации
Qwertie
Изменил его, чтобы вместо этого использовать блок. Таким образом, вы не сможете забыть о возвращении в нормальное время.
jBilbo
10

да, поскольку Oinпредлагает be_withinсопоставление - лучшая практика

... и еще несколько вариантов использования -> http://www.eq8.eu/blogs/27-rspec-be_within-matcher

Но еще один способ справиться с этим - использовать встроенные атрибуты middayи middnightатрибуты Rails .

it do
  # ...
  stubtime = Time.now.midday
  expect(Time).to receive(:now).and_return(stubtime)

  patch :update 
  expect(@article.reload.updated_at).to eq(stubtime)
  # ...
end

Теперь это просто для демонстрации!

Я бы не стал использовать это в контроллере, поскольку вы заглушаете все вызовы Time. Новые вызовы => все временные атрибуты будут иметь одинаковое время => может не подтвердить концепцию, которую вы пытаетесь достичь. Я обычно использую его в составленных объектах Ruby, подобных этому:

class MyService
  attr_reader :time_evaluator, resource

  def initialize(resource:, time_evaluator: ->{Time.now})
    @time_evaluator = time_evaluator
    @resource = resource
  end

  def call
    # do some complex logic
    resource.published_at = time_evaluator.call
  end
end

require 'rspec'
require 'active_support/time'
require 'ostruct'

RSpec.describe MyService do
  let(:service) { described_class.new(resource: resource, time_evaluator: -> { Time.now.midday } ) }
  let(:resource) { OpenStruct.new }

  it do
    service.call
    expect(resource.published_at).to eq(Time.now.midday)    
  end
end

Но, честно говоря, я рекомендую придерживаться be_withinсопоставления даже при сравнении Time.now.midday!

Так что да, пожалуйста, используйте be_withinсовпадение;)


обновление 2017-02

Вопрос в комментарии:

что, если время указано в хэше? каким-либо способом заставить expect (hash_1) .to eq (hash_2) работать, когда некоторые значения hash_1 - это время до db, а соответствующие значения в hash_2 - после db? -

expect({mytime: Time.now}).to match({mytime: be_within(3.seconds).of(Time.now)}) `

вы можете передать любой сопоставитель RSpec сопоставителю match(например, вы даже можете проводить тестирование API с чистым RSpec )

Что касается "post-db-times", я думаю, вы имеете в виду строку, которая генерируется после сохранения в БД. Я бы предложил разделить этот случай с двумя ожиданиями (одно обеспечивает хэш-структуру, второе проверяет время), поэтому вы можете сделать что-то вроде:

hash = {mytime: Time.now.to_s(:db)}
expect(hash).to match({mytime: be_kind_of(String))
expect(Time.parse(hash.fetch(:mytime))).to be_within(3.seconds).of(Time.now)

Но если этот случай слишком часто встречается в вашем наборе тестов, я бы предложил написать свой собственный сопоставитель RSpec (например be_near_time_now_db_string), преобразующий время строки db в объект Time, а затем использовать его как часть match(hash):

 expect(hash).to match({mytime: be_near_time_now_db_string})  # you need to write your own matcher for this to work.
equivalent8
источник
что, если время указано в хэше? каким-либо способом заставить expect(hash_1).to eq(hash_2)работать, когда некоторые значения hash_1 являются временами до db, а соответствующие значения в hash_2 - значениями после db?
Майкл Джонстон
Я думал о произвольном хеш-коде (моя спецификация утверждает, что операция вообще не меняет запись). Но спасибо!
Майкл Джонстон
8

Вы можете преобразовать объект date / datetime / time в строку, поскольку она хранится в базе данных с помощью to_s(:db).

expect(@article.updated_at.to_s(:db)).to eq '2015-01-01 00:00:00'
expect(@article.updated_at.to_s(:db)).to eq Time.current.to_s(:db)
Томас Клемм
источник
7

Самый простой способ обойти эту проблему - создать current_timeвспомогательный тестовый метод, например:

module SpecHelpers
  # Database time rounds to the nearest millisecond, so for comparison its
  # easiest to use this method instead
  def current_time
    Time.zone.now.change(usec: 0)
  end
end

RSpec.configure do |config|
  config.include SpecHelpers
end

Теперь время всегда округляется до ближайшей миллисекунды, чтобы сравнение было простым:

it "updates updated_at attribute" do
  Timecop.freeze(current_time)

  patch :update
  @article.reload
  expect(@article.updated_at).to eq(current_time)
end
Сэм
источник
1
.change(usec: 0)очень помогает
Коэн.
2
Мы можем использовать этот .change(usec: 0)трюк для решения проблемы, не прибегая к помощи специального помощника. Если первая строка, Timecop.freeze(Time.current.change(usec: 0))то мы можем просто сравнить .to eq(Time.now)в конце.
Harry Wood
0

Поскольку я сравнивал хеши, большинство этих решений у меня не работали, поэтому я обнаружил, что самым простым решением было просто получить данные из сравниваемого хеша. Поскольку время updated_at на самом деле мне не подходит для тестирования, это работает нормально.

data = { updated_at: Date.new(2019, 1, 1,), some_other_keys: ...}

expect(data).to eq(
  {updated_at: data[:updated_at], some_other_keys: ...}
)
Qwertie
источник