Problem z porównaniem czasu z RSpec

119

Używam Ruby on Rails 4 i rspec-rails gem 2.14. W przypadku mojego obiektu chciałbym porównać bieżący czas z updated_atatrybutem obiektu po uruchomieniu akcji kontrolera, ale mam kłopoty, ponieważ specyfikacja nie spełnia wymagań. To znaczy, biorąc pod uwagę poniższy kod specyfikacji:

it "updates updated_at attribute" do
  Timecop.freeze

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

Po uruchomieniu powyższej specyfikacji pojawia się następujący błąd:

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 ==)

Jak mogę sprawić, by specyfikacja przeszła?


Uwaga : próbowałem również następujących (zwróć uwagę na utcdodatek):

it "updates updated_at attribute" do
  Timecop.freeze

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

ale specyfikacja nadal nie spełnia warunków (zwróć uwagę na różnicę wartości „otrzymano”):

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
źródło
Jest to porównywanie identyfikatorów obiektów, stąd tekst z inspect jest zgodny, ale pod spodem znajdują się dwa różne obiekty Time. Możesz po prostu użyć ===, ale może to ucierpieć z powodu przekroczenia drugich granic. Prawdopodobnie najlepiej jest znaleźć lub napisać własny matcher, w którym przeliczasz na sekundy epoki i dopuszczasz niewielką absolutną różnicę.
Neil Slater,
Jeśli zrozumiałem, że mówisz o „przekraczaniu drugich granic”, problem nie powinien się pojawić, ponieważ używam klejnotu Timecop, który „zamraża” czas.
Backo,
Ach, przegapiłem to, przepraszam. W takim przypadku po prostu użyj ===zamiast ==- obecnie porównujesz object_id dwóch różnych obiektów Time. Chociaż Timecop nie zamraża czasu serwera bazy danych. . . więc jeśli twoje znaczniki czasu są generowane przez RDBMS, to nie zadziała (spodziewam się, że nie stanowi to dla ciebie problemu)
Neil Slater,

Odpowiedzi:

156

Obiekt Ruby Time zachowuje większą precyzję niż baza danych. Gdy wartość jest odczytywana z bazy danych, jest zachowywana tylko z dokładnością do mikrosekund, podczas gdy reprezentacja w pamięci jest dokładna do nanosekund.

Jeśli nie obchodzi Cię różnica w milisekundach, możesz wykonać to_s / to_i po obu stronach swoich oczekiwań

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

lub

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

Zapoznaj się z tym, aby uzyskać więcej informacji o tym, dlaczego czasy są różne

usha
źródło
Jak widać w kodzie z pytania, używam Timecopperełki. Czy powinien po prostu rozwiązać problem poprzez „zamrożenie” czasu?
Backo,
Oszczędzasz czas w bazie danych i pobierasz go (@ article.updated_at), co powoduje utratę nanosekund, podczas gdy Time.now zachowuje nanosekundę. Powinno to jasno wynikać z pierwszych kilku linijek mojej odpowiedzi
usha
3
Zaakceptowana odpowiedź jest prawie o rok starsza niż odpowiedź poniżej - dopasowanie be_within jest znacznie lepszym sposobem radzenia sobie z tym: A) nie potrzebujesz klejnotu; B) działa dla każdego typu wartości (liczba całkowita, zmiennoprzecinkowa, data, godzina itp.); C)
Natywnie
3
To jest „OK” rozwiązanie, ale zdecydowanie be_withinwłaściwe
Francesco Belladonna
Witam Używam porównań datetime z oczekiwaniami związanymi z kolejką zadań expect {FooJob.perform_now}.to have_enqueued_job(FooJob).at(some_time) Nie jestem pewien, czy .atdopasowujący zaakceptowałby czas przekonwertowany na liczbę całkowitą, gdy .to_iktoś napotkał ten problem?
Cyril Duchon-Doris
200

Uważam, że używanie be_withindomyślnego matchera rspec jest bardziej eleganckie:

expect(@article.updated_at.utc).to be_within(1.second).of Time.now
Oin
źródło
2
.000001 s jest trochę napięty. Próbowałem nawet 0,001 i czasami kończyło się niepowodzeniem. Wydaje mi się, że nawet 0,1 sekundy dowodzi, że czas jest ustawiony na teraz. Wystarczająco dobre do moich celów.
smoyth
3
Myślę, że ta odpowiedź jest lepsza, ponieważ wyraża intencję testu, a nie przesłania go za niektórymi niepowiązanymi metodami, takimi jak to_s. Również, to_ia to_smoże nie rzadko, jeśli czas jest blisko końca sekundy.
B Seven
2
Wygląda na to, że be_withindopasowanie zostało dodane do RSpec 2.1.0 7 listopada 2010 roku i od tego czasu kilkakrotnie ulepszane. rspec.info/documentation/2.14/rspec-expectations/…
Mark Berry
1
Rozumiem undefined method 'second' for 1:Fixnum. Czy jest coś, czego potrzebuję require?
David Moles
3
@DavidMoles .secondto rozszerzenie rails: api.rubyonrails.org/classes/Numeric.html
jwadsack
10

tak, ponieważ Oinsugeruje, że be_withindopasowywanie jest najlepszą praktyką

... i ma więcej zastosowań -> http://www.eq8.eu/blogs/27-rspec-be_within-matcher

Ale jeszcze jednym sposobem, jak sobie z tym poradzić, jest użycie wbudowanych Railsów middayi middnightatrybutów.

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

Teraz to tylko do demonstracji!

Nie użyłbym tego w kontrolerze, ponieważ odrzucasz wszystkie połączenia Time.new => wszystkie atrybuty czasu będą miały ten sam czas => mogą nie udowodnić koncepcji, którą próbujesz osiągnąć. Zwykle używam go w komponowanych obiektach Ruby podobnych do tego:

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

Ale szczerze polecam trzymać się be_withindopasowania nawet przy porównywaniu Time.now.midday!

Więc tak, proszę trzymać się be_withindopasowywania;)


aktualizacja 2017-02

Pytanie w komentarzu:

co jeśli czasy są w skrócie? w jakikolwiek sposób sprawić, by oczekiwanie (hash_1) .to eq (hash_2) zadziałało, gdy niektóre wartości hash_1 są pre-db-times, a odpowiadające im wartości w hash_2 są post-db-times? -

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

możesz przekazać dowolne dopasowanie RSpec do matchdopasowującego (więc np. możesz nawet wykonać testy API z czystym RSpec )

Jeśli chodzi o „czasy post-db”, myślę, że masz na myśli ciąg, który jest generowany po zapisaniu do DB. Proponuję oddzielić ten przypadek od 2 oczekiwań (jedno zapewniające strukturę skrótu, drugie sprawdzające czas), więc możesz zrobić coś takiego:

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)

Ale jeśli ten przypadek jest zbyt często w twoim zestawie testów, sugerowałbym napisanie własnego dopasowania RSpec (np. be_near_time_now_db_string) Przekonwertowania czasu ciągu db na obiekt Time, a następnie użycie tego jako części match(hash):

 expect(hash).to match({mytime: be_near_time_now_db_string})  # you need to write your own matcher for this to work.
odpowiednik 8
źródło
co jeśli czasy są w skrócie? jakikolwiek sposób na expect(hash_1).to eq(hash_2)pracę, gdy niektóre wartości hash_1 są przed czasami db, a odpowiadające im wartości w hash_2 są post-db-times?
Michael Johnston,
Myślałem o arbitralnym skrócie (moja specyfikacja zapewnia, że ​​operacja w ogóle nie zmienia rekordu). Ale dzięki!
Michael Johnston,
10

Stary post, ale mam nadzieję, że pomoże to każdemu, kto wejdzie tutaj w poszukiwaniu rozwiązania. Myślę, że łatwiej i pewniej jest po prostu utworzyć datę ręcznie:

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

Gwarantuje to, że przechowywana data jest właściwa, bez robienia to_xlub martwienia się o wartości dziesiętne.

jBilbo
źródło
Upewnij się, że odmroziłeś czas pod koniec specyfikacji
Qwertie
Zmieniono go, aby zamiast tego używał bloku. W ten sposób nie możesz zapomnieć o powrocie do normalnego czasu.
jBilbo
8

Możesz przekonwertować obiekt daty / daty / godziny / godziny na ciąg, który jest przechowywany w bazie danych za pomocą 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)
Thomas Klemm
źródło
7

Najłatwiejszym sposobem obejścia tego problemu jest utworzenie current_timemetody pomocniczej testu, takiej jak:

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

Teraz czas jest zawsze zaokrąglany do najbliższej milisekundy, aby porównania były proste:

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

  patch :update
  @article.reload
  expect(@article.updated_at).to eq(current_time)
end
Sam
źródło
1
.change(usec: 0)jest bardzo pomocny
Koen.
2
Możemy użyć tej .change(usec: 0)sztuczki, aby rozwiązać problem bez korzystania z pomocnika specyfikacji. Jeśli pierwsza linia to Timecop.freeze(Time.current.change(usec: 0)), możemy po prostu porównać .to eq(Time.now)na końcu.
Harry Wood,
0

Ponieważ porównywałem hashe, większość tych rozwiązań nie działała dla mnie, więc stwierdziłem, że najłatwiejszym rozwiązaniem jest po prostu pobranie danych z hasha, który porównywałem. Ponieważ czasy updated_at nie są dla mnie przydatne do testowania, działa to dobrze.

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

expect(data).to eq(
  {updated_at: data[:updated_at], some_other_keys: ...}
)
Qwertie
źródło