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

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


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

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.

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.


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)

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

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: ...}
)
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.