SEAN_BLOG

プログラミング etc.

Note to self: ActiveSupport::TimeZone とString での時間の比較

先日TimeZone 周りでハマったので、備忘用にメモします。

結論

  • Time.zone.parse('2019-01-01')2019-01-01 は異なる。*1
  • ActiveSupport::TimeZoneString での時間の比較は注意が必要。
  • Time.zone.parse は比較対象もTime.zone.parse にするのがbetter。

内容

先日RoR で書かれたとあるコードのリファクタリングを行う機会がありました。 メインロジックの実装を完了し、Rspec でテストコードを実装していました。 その日は珍しく集中力が続き、スラスラとテストコードの実装が進みました。 が、それも束の間

$ bundle exec rspec spec/helpers/test_helper.rb
F

Failures:

  1) TestHelper#on_sale? when the released date is on/past 2019-01-01 should be true
     Failure/Error: expect(on_sale?(album)).to be true

       expected #<TrueClass:20> => true
            got #<FalseClass:0> => false

       Compared using equal?, which compares object identity,
       but expected and actual are not the same object. Use
       `expect(actual).to eq(expected)` if you don't care about
       object identity in this example.
     # ./spec/helpers/test_helper.rb:10:in `block (4 levels) in <top (required)>'

Finished in 0.22805 seconds
1 example, 1 failure

Failed examples:

rspec ./spec/helpers/test_helper.rb:9 # TestHelper#on_sale?  when the released date is on/past 2019-01-01 should be true

Rspec が落ちました。 ソースを確認してみると、

module TestHelper
  def on_sale?(album)
    album.released_at >= '2019-01-01'
  end
end
require 'spec_helper'

describe TestHelper do
  describe '#on_sale?' do
    let!(:album) { create(:album, released_at: Time.zone.parse('2019-01-01')) }

    context 'when the released date is on/past 2019-01-01' do
      it 'should be true' do
        expect(on_sale?(album)).to be true
      end
    end
  end
end

*2

Time.zone.parse('2019-01-01') >= 2019-01-01

false ( ´Д`)y━・~~

・・・・・

ペーペーの僕には全く理由が分かりませんでした。

そこで、困った時のbin/rails c です。

$ bin/rails c
pry(main)> t1 = Time.zone.parse('2019-01-01')
=> Mon, 01 Jan 2018 00:00:00 JST +09:00 >= Mon, 01 Jan 2018 00:09:00 JST +09:00
pry(main)> string_time = '2019-01-01'
=> "2019-01-01"
pry(main)> t1 >= string_time
=> false

t1ActiveSupport::TimeWithZone クラスで、t2String クラスな訳で、、、

この時の脳内は、

  • Ruby(Rails) でそもそもActiveSupport::TimeWithZoneString は比較できない!?
  • 比較できたとして、t2("2019-01-01") は何時なのか?String の場合00:00:00 じゃない!?

みたいな感じでした。

そこで、引き続きコンソール叩いてみました。

pry(main)> t2 = Time.zone.parse('2019-01-01 23:59:59')
pry(main)> t2 >= string_time
=> true
pry(main)> t3 = Time.zone.parse('2019-01-01 12:00:00')
pry(main)> t3 >= string_time
=> true
pry(main)> t4 = Time.zone.parse('2019-01-01 06:00:00')
pry(main)> t4 >= string_time
=> false
pry(main)> t5 = Time.zone.parse('2019-01-01 09:00:00')
pry(main)> t5 >= string_time
=> true 

この辺りで気がつきました。TimeZone の存在に、、、何やってんだよって感じですよね、本当に。

pry(main)> '2019-01-01'.to_time
=> 2019-01-01 00:00:00 +0000

やっぱり、 +0000 ってなってる。String の場合、UTC(協定世界時)で時間が設定されるようです。 一方、Time.zone.parse('2019-01-01') の方はJST で設定されています。 皆さんご存知のように、JSTUTC に比べて9時間早いので、今回の比較は実際には下記の通りです。

Mon, 01 Jan 2018 00:00:00 JST +09:00 >= Mon, 01 Jan 2018 00:09:00 JST +09:00

これでは、true になるはずがありませんね。

そこで、今回は下記の通り変更しました。

module TestHelper
  def on_sale?(album)
-    album.released_at >= '2019-01-01'
+    album.released_at >= Time.zone.parse('2019-01-01')
  end
end
require 'spec_helper'

describe TestHelper do
  describe '#on_sale?' do
    let!(:album) { create(:album, released_at: Time.zone.parse('2019-01-01')) }

    context 'when the released date is on/past 2019-01-01' do
      it 'should be true' do
        expect(on_sale?(album)).to be true
      end
    end
  end
end

この箇所に限らずJST を基本的に使っているので、今回のように急にUTC が出てきて予期せぬバグが出ないようにメインロジックをTime.zone.parse を用いて修正しました。

$ bundle exec rspec spec/helpers/test_helper.rb
.

Finished in 0.35198 seconds
1 example, 0 failures

無事テストもパスしました。( ´∀`)

ちなみに、このTimeZone ですがconfig/application.rb で設定できます。

class Application < Rails::Application
  ...
  config.time_zone = 'Tokyo' # ここ
  ...
end

参考:

api.rubyonrails.org

所感

  • テストを書くことって大事ですね。最近テストコードの重要性をひしひしと感じていますが、今回さらに強く思いました。プログラミング始めたての頃は動けばOK みたいな感じでやってましたが、今思えば恐ろしいことです。
  • Time.zone と何回もタイピングしていながら、TimeZone に気がつかなかったのはとても悔しいです。そして、このエントリーを書きながら自分へっぽこぶりにつくづく嫌になってきました。
  • とっても遠回りをしてしまったけれど、仮説検証のサイクルを回せたのはよかったかなぁと。Lv. 3 からLv. 4 くらいにはなったかなぁと。

*1:Timezone がJST の場合。

*2:大人の事情にてソースコードが実際のものとは異なります。