Problemas para comparar o tempo com RSpec

119

Estou usando Ruby on Rails 4 e rspec-rails gem 2.14. Para um meu objeto, gostaria de comparar a hora atual com o updated_atatributo do objeto após a execução de uma ação do controlador, mas estou com problemas porque a especificação não passa. Ou seja, dado o seguinte está o código de especificação:

it "updates updated_at attribute" do
  Timecop.freeze

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

Quando executo a especificação acima, recebo o seguinte erro:

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

Como posso fazer a especificação passar?


Observação : tentei também o seguinte (observe a utcadição):

it "updates updated_at attribute" do
  Timecop.freeze

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

mas a especificação ainda não passa (observe a diferença de valor "obtido"):

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
fonte
Ele está comparando os ids de objeto, portanto, o texto de inspect está correspondendo, mas por baixo você tem dois objetos Time diferentes. Você poderia apenas usar ===, mas isso pode sofrer ao cruzar limites secundários. Provavelmente, o melhor é encontrar ou escrever seu próprio matcher, no qual você converte para segundos de época e permite uma pequena diferença absoluta.
Neil Slater
Se eu entendi você relatando "cruzar os segundos limites", o problema não deveria surgir, já que estou usando a gema Timecop que "congela" o tempo.
Backo
Ah, eu perdi isso, desculpe. Nesse caso, apenas use em ===vez de ==- atualmente você está comparando o object_id de dois objetos Time diferentes. Embora o Timecop não congele o tempo do servidor do banco de dados. . . então, se seus carimbos de data / hora estiverem sendo gerados pelo RDBMS, não funcionaria (espero que isso não seja um problema para você aqui)
Neil Slater

Respostas:

156

O objeto Ruby Time mantém uma precisão maior do que o banco de dados. Quando o valor é lido de volta no banco de dados, ele é preservado apenas na precisão de microssegundos, enquanto a representação na memória é precisa em nanossegundos.

Se você não se importa com a diferença de milissegundos, você poderia fazer um to_s / to_i em ambos os lados da sua expectativa

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

ou

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

Consulte isso para obter mais informações sobre por que os horários são diferentes

usha
fonte
Como você pode ver no código da pergunta, eu uso a Timecopgema. Deveria apenas resolver o problema "congelando" o tempo?
Backo
Você economiza o tempo no banco de dados e o recupera (@ article.updated_at), o que o faz perder os nanossegundos, enquanto que Time.now retém o nanossegundo. Deve ficar claro desde as primeiras linhas da minha resposta
usha
3
A resposta aceita é quase um ano mais velha do que a resposta abaixo - o matcher be_within é uma maneira muito melhor de lidar com isso: A) você não precisa de uma gema; B) funciona para qualquer tipo de valor (inteiro, float, data, hora, etc.); C) faz parte nativamente do RSpec
notaceo
3
Esta é uma solução "OK", mas definitivamente be_withiné a certa
Francesco Belladonna
Olá, estou usando comparações de data e hora com expectativas de atraso de enfileiramento de trabalhos. expect {FooJob.perform_now}.to have_enqueued_job(FooJob).at(some_time) Não tenho certeza se o .atmatcher aceitaria um tempo convertido em inteiro com .to_ialguém que enfrentou esse problema?
Cyril Duchon-Doris
200

Acho que usar o be_withinmatcher rspec padrão é mais elegante:

expect(@article.updated_at.utc).to be_within(1.second).of Time.now
Oin
fonte
2
.000001 s é um pouco apertado. Tentei até 0,001 e às vezes estava falhando. Mesmo 0,1 segundo eu acho que prova que o tempo está sendo definido para agora. Bom o suficiente para meus propósitos.
smoyth
3
Acho que essa resposta é melhor porque expressa a intenção do teste, em vez de obscurecê-la por trás de alguns métodos não relacionados, como to_s. Além disso, to_ie to_spode falhar com pouca frequência se o tempo estiver próximo ao final de um segundo.
B Sete
2
Parece que o be_withinmatcher foi adicionado ao RSpec 2.1.0 em 7 de novembro de 2010 e aprimorado algumas vezes desde então. rspec.info/documentation/2.14/rspec-expectations/…
Mark Berry
1
Eu entendo undefined method 'second' for 1:Fixnum. Existe algo que eu preciso require?
David Moles
3
@DavidMoles .secondé uma extensão do rails: api.rubyonrails.org/classes/Numeric.html
jwadsack
10

Sim, pois Oinestá sugerindo que o be_withinmatcher é a melhor prática

... e tem mais alguns uscases -> http://www.eq8.eu/blogs/27-rspec-be_within-matcher

Mas mais uma maneira de lidar com isso é usar atributos middaye embutidos do Rails middnight.

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

Agora, isso é apenas para demonstração!

Eu não usaria isso em um controlador, pois você está copiando todas as chamadas Time.new => todos os atributos de tempo terão o mesmo tempo => pode não provar o conceito que você está tentando alcançar. Eu geralmente o uso em objetos Ruby compostos semelhantes a este:

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

Mas, honestamente, eu recomendo ficar com o be_withinmatcher, mesmo quando estiver comparando Time.now.midday!

Então, sim, por be_withinfavor, use o combinador;)


atualização 2017-02

Pergunta no comentário:

e se os tempos estiverem em um Hash? alguma maneira de fazer expect (hash_1) .to eq (hash_2) funcionar quando alguns valores hash_1 são pré-tempos db e os valores correspondentes em hash_2 são tempos pós-db? -

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

você pode passar qualquer matcher RSpec para o matchmatcher (então, por exemplo, você pode até mesmo fazer testes de API com RSpec puro )

Quanto a "tempos pós-db", acho que você quer dizer string que é gerada após salvar no banco de dados. Eu sugeriria desacoplar este caso de 2 expectativas (uma garantindo a estrutura hash, a segunda verificando o tempo) para que você possa fazer algo como:

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)

Mas se este caso for muito frequente em seu conjunto de testes, eu sugeriria escrever seu próprio matcher RSpec (por exemplo be_near_time_now_db_string) convertendo o tempo da string db para o objeto Time e então usar isso como parte de match(hash):

 expect(hash).to match({mytime: be_near_time_now_db_string})  # you need to write your own matcher for this to work.
equivalente 8
fonte
e se os tempos estiverem em um Hash? alguma maneira de fazer expect(hash_1).to eq(hash_2)funcionar quando alguns valores hash_1 são pré-tempos db e os valores correspondentes em hash_2 são tempos pós-db?
Michael Johnston
Eu estava pensando em um hash arbitrário (minha especificação afirma que uma operação não altera um registro). Mas obrigado!
Michael Johnston
10

Postagem antiga, mas espero que ajude quem entrar aqui por uma solução. Acho que é mais fácil e confiável apenas criar a data manualmente:

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

Isso garante que a data armazenada seja a correta, sem fazer to_xou se preocupar com decimais.

jBilbo
fonte
Certifique-se de descongelar o tempo no final da especificação
Qwertie
Alterado para usar um bloco. Dessa forma, você não pode esquecer de voltar ao tempo normal.
jBilbo
8

Você pode converter o objeto de data / data / hora / hora em uma string conforme é armazenado no banco de dados com 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
fonte
7

A maneira mais fácil que encontrei de contornar esse problema é criar um current_timemétodo auxiliar de teste assim:

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

Agora, o tempo é sempre arredondado para o milissegundo mais próximo para que as comparações sejam diretas:

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

  patch :update
  @article.reload
  expect(@article.updated_at).to eq(current_time)
end
Sam
fonte
1
.change(usec: 0)é muito útil
Koen.
2
Podemos usar esse .change(usec: 0)truque para resolver o problema sem usar um auxiliar de especificação também. Se a primeira linha for, Timecop.freeze(Time.current.change(usec: 0))então podemos simplesmente comparar .to eq(Time.now)no final.
Harry Wood
0

Como eu estava comparando hashes, a maioria dessas soluções não funcionou para mim, então descobri que a solução mais fácil era simplesmente pegar os dados do hash que estava comparando. Uma vez que os updates_at times não são realmente úteis para eu testar, isso funciona bem.

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

expect(data).to eq(
  {updated_at: data[:updated_at], some_other_keys: ...}
)
Qwertie
fonte