前回までのあらすじ
- ひとりTDDBCをやります
- お題はt_wadaさんのTDDBCお題スライド P.5
- Githubのこのリポジトリで進めます
- Vol.1
- Vol.2
- Vol.3
仕様変更その2
一定時間経ったデータは消えて欲しいとのことです。
cache = LRUCache.new(2, timeout: 60) # タイムアウトを60秒に設定
cache[:a] = 'alpha'
# 59秒以内にデータを取り出す
cache[:a] # => 'alpha'
# 60秒以上経った後でデータを取り出す
cache[:a] # => nil
まずLRUCache
インスタンスを作る時に、timeout
オプションで秒数を渡せるようにします。
- データを入れてから、まだ
timeout
秒経っていない場合は取り出せる - データを入れてから、
timeout
秒以上経った場合は取り出せない(nil
を返す)
というふうにしてみます。
レビュー
テスト
今回は時間が絡む動作をテストしないといけないので
テストコードでは時間をコントロールする必要があります。
例えば、60秒以上経ったデータが消えていることをテストしたい場合
簡単に書くと、
cache[:a] = 'alpha'
sleep(60)
expect(cache[:a]).to be_nil
cache[:z] = 'zulu'
expect(cache[:z]).to eq('zulu')
こういうふうに書けます。
が、このテストを実行すると必ず60秒かかってしまいます。
同じようなテストが複数あると、どんどんテストの実行が遅くなります。
TDDでは、Red => Green => Refactor
のサイクルをテンポよく回すことがとても重要で
テストはできる限り速く実行したいので、この方法はちょっと使えません。
反対に、タイムアウトを0.1秒に設定し、
cache[:a] = 'alpha'
sleep(0.1)
expect(cache[:a]).to be_nil
cache[:z] = 'zulu'
expect(cache[:z]).to eq('zulu')
こういうテストを書いたとします。
テストを速く実行する点では問題ありませんが
:z
のデータを扱うところが微妙です。
cache[:z] = 'zulu' # 0.1秒以内に処理が完了すれば...
expect(cache[:z]).to eq('zulu') # => 'zulu' OK!
cache[:z] = 'zulu' # 何らかの理由で0.1秒以内に処理が完了しないと...
expect(cache[:z]).to eq('zulu') # => nil NG!
このように、テストを実行する度に結果が変わってしまう可能性があります。
プロダクションコードとテストが同じであれば、テスト結果も常に同じでないといけないので
この方法も使えません。
という訳で
RSpec.describe LRUCache, 'タイムアウトを60秒に設定' do
let(:cache) { LRUCache.new(2, timeout: 60) }
it '60秒以上経ったデータは消える' do
stub_now(Time.now - 60) do # 60秒前にデータを入れたことにする
cache[:a] = 'alpha'
end
cache[:b] = 'bravo'
expect(cache[:a]).to be_nil
expect(cache[:b]).to eq('bravo')
end
it '60秒経っていないデータは残る' do
stub_now(Time.now - 59) do # 60秒経っていないことを保証する
cache[:x] = 'x-ray'
cache[:y] = 'yankee'
cache[:x]
cache[:z] = 'zulu'
expect(cache[:x]).to eq('x-ray')
expect(cache[:y]).to be_nil
expect(cache[:z]).to eq('zulu')
end
end
end
テストはこのようにしました。
時間が絡むテストをしたい時はtimecopなどを使って、時間を制御すると便利なのですが
今回はそんなに凝ったことをしないので、ヘルパーメソッドを自作しました。
def stub_now(fake_time)
allow(Time).to receive(:now) { fake_time }
yield
allow(Time).to receive(:now).and_call_original
end
stub_now
メソッドに渡したブロック内で、Time.now
を呼び出すと
必ずfake_time
を返すようにし
ブロックを抜けるとTime.now
は本来の時間を返すようになっています。
(このへんのスタブ周りもRSpec3.0で大きく変わりましたね)
プロダクションコード
class History
class Line < Struct.new(:key)
def initialize(key)
super(key)
@created_at = Time.now
end
def expired?(expire_at)
@created_at <= expire_at
end
end
end
前のバージョンでは、History
が保持するキーはキーそのものでしたが
今回はタイムアウトを考慮する必要があるので、キーを入れておくLine
クラスを作り
キーとなるオブジェクトと一緒に、Line
オブジェクトが生成された日時も記録できるようにしました。
class Line < Struct.new(:key, :created_at)
としていないのは、
Line#==
メソッドでkey
だけを同値性の基準としたいからです。
class History
def initialize(timeout)
@lines = []
@timeout = timeout
end
def record(key)
line = Line.new(key)
@lines.tap {|x| x.delete(line) } << line
line.key
end
def keys(capacity)
@lines.slice!(0, @lines.size - capacity)
timeout
@lines.map {|l| l.key }
end
private
def timeout
return unless @timeout
@lines.reject! {|l| l.expired?(Time.now - @timeout) }
end
end
History
クラスの方は、キャッシュすべきキーを取得するkeys
メソッドの中で
@timeout
秒以上前に作成されたLine
(キー)も削除するようにしています。