2019-08-02に更新

月末だけ失敗するspecテストと対策(テストを時刻に依存させるな運動)

この記事で言う現在時刻というのは、例えばTime.zone.nowとか、Date.current, Date.todayのような、今日とか現在時刻、をとってくるメソッドのことです。
railsではtimezoneの扱いを大事にするため、Time.nowじゃなくてTime.zone.nowを使う、みたいなノウハウがありますが、今回の話はそれとは関係がありません。
specテストにおいて、そういう「現在時刻に関するメソッド」を安易に使うと、「特定の日だけ落ちるテスト」「特定の時間だけ落ちるテスト」になりやすいのでやめよう、という話。

例えばこんなケース

渡されたdateに対して、その月の残りの日付を配列で返す関数 remaining_days_of_month を作ります。例えば 1/28が渡されたら、[29,30,31] を返します。31日だと、残りの日付はないので [] です

コーディングテストに良さそうな題材ですね。これをどうにかして実装したとして、この関数に対するspecテストを考えましょう。

「これ、引数に渡したその日は含まれないのよね」

it 'not contains the day' do
  expect(remaining_days_of_month(Time.zone.today)).not_to include Time.zone.today.day
end

「で、その次の日から始まる、と」

it 'contains next day' do
  expect(remaining_days_of_month(Time.zone.today)).to include Date.tomorrow.day
end

いろいろつっこみどころはあるかと思いますが、話の都合で、これがコードレビューを通ったことにしましょう。テストはなにか書いてあればOKという職場、あるでしょ?

月末の悲劇

月末の夜、リリースに向けてコードを書く職場で悲鳴が上がります。
「なんかテスト落ちてるぞ!」
「Aくん、rspec落ちてるから確認して」

AくんはCircleCIで落ちた箇所を確認します。おかしいな。こんなところ触ってないのに、これ一体何なんだろう...触ったこともないところだから全然わからん...これぼく関係ないんじゃないの?だからテストって嫌いなんだ...

問題

簡単な例なので大半の方は気づいていると思いますが、問題のコードはこれです。

it 'contains next day' do
  expect(remaining_days_of_month(Time.zone.today)).to include Date.tomorrow.day
end

このコード、通常はテストが通りますが、月末最終日に限り、Time.zone.todayはその月、Date.tomorrowは翌月を指すようになるのでテストが失敗します。

この一行なら誰だってわかるけど、これ10行くらい書いた中に埋め込まれると結構わからなくなるのです....

対策

よく言われる対策は「TimeCop / TimeHelpersを使って時間を固定する」です。

ですが、正直この程度のことにそんな大道具持ち出すのもどうなのよ、そういうことしていると、今度は開放し忘れて別の箇所で悲劇を引き起こすんじゃないの?

ぼくがすすめている対策は、「rspecで現在時刻は使わない」です。

例えば今回のテストはどう書くのが正解だったのか。

describe 'remaining_days_of_month' do
  it 'contains remaining days' do
    let(:date) { Date.new(2019, 1, 28) }
    expect(remaining_days_of_month(date)).to match_array [29, 30, 31]
  end
end

テスト日付を固定します。そうすると答えもおのずから決まるから、includeじゃなくてmatch_arrayで確認することができる。

そもそも、テストを実施する日によってテスト内容が変わる時点でおかしいですよね。specテストに書いたコードにバグがあっても、specテストをspecテストすることはできないのだから、テストは可能な限りシンプルなロジックであるべきで、Time.zone.nowみたいに値が変わるものを使うのは良くないことです。

よくある反論

よくある反論は、「現在時刻使っておけばいろんな日付が試せるから潜在バグを洗い出せるよ」です。

甘えるんじゃねえ!

その潜在バグとやらが自分の手元で見つかるのならともかく、わけのわからんタイミングで爆発して始末させられる方の身にもなってください。

境界値が不安なのであれば、自分でテストを書きましょう

  it 'contains remaining days' do
    let(:date) { Date.new(2019, 1, 28) }
    expect(remaining_days_of_month(date)).to match_array [29, 30, 31]
  end
  it 'return empty at the end of the month' do
    let(:date) { Date.new(2019, 1, 31) }
    expect(remaining_days_of_month(date)).to match_array []
  end

使っても良いケース

メソッド内に現在時刻が使われている場合、テスト側も現在時刻を使わざるを得ないケースがあります。
例えば、「今日以降の日付を配列で返す関数」のような場合です。

ただこの場合でも、こういう実装にしてあれば、現在時刻を使わなくてもテストすることができます。

# 指定された日以降の日付を配列で返す. 省略すると当日が使われる
def remaining_days_of_month(date = Time.zone.today)
:

この辺の実装の工夫について、下記が詳しいです。

「現在時刻」を外部入力とする設計と、その実装のこと

で、いろいろな事情によりそこまでやれない場合に、TimeHelpersで時間を固定した上で、Time.zone.now/Time.zone.todayを使うのは仕方ないかなあ、という感じですね。
specテストにおいて、固定しないTime.zone.now/Time.zone.todayを使ってよい場面は存在しません。

まとめ

現在時刻を使うことによって、月末だけ失敗するrspecバグを埋め込むパターンを説明しました。

いくつかのrailsプロジェクトを渡り歩いていて、このルールはあたりまえのように守られているところもあれば、どんなに説明してもご理解いただけないところもあったりして苦労しています。
この記事を示すことで誰でもわかってもらえるようになるといいなあ。

Originally published at qiita.com

daisuke furukawa

おひるねのできるフリーランサー。「モバイラーズオアシス」の中の人でもあります。

Crieitは個人で開発中です。 興味がある方は是非記事の投稿をお願いします! どんな軽い内容でも嬉しいです。
なぜCrieitを作ろうと思ったか

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください!

ボードとは?

関連記事

コメント