時間に関する処理は、ユニットテスト作成のチャンス
(前置き)クライアントサイドの処理のユニットテストは、UIが絡む部分が多い、クライアント側では状態を持たずにサーバーサイドから受け取ったデータを描画するだけの実装になっていることが多い、などの理由からテストを書いても短期的にはペイしないことが多い気がします(もちろん、長期的にはペイする可能性が高いし極力テストを書けるなら書くべきだと思う)。しなしながらそんな中でも、時間に関する処理については、下記の理由でユニットテストが短期的にもペイする可能性が高いように思います。
- デバッグするコストが大きい(端末時間やサーバーサイドの返り値を変更し、状態を再現する必要がある)
- 上記のため、何らかの修正で問題が発生したとしても気付くことが比較的難しい
例えばですがよくあるのは端末に一定時間の間だけ情報をキャッシュしたいといった場合や、特定の期間(季節モノのイベントやキャンペーンなど)にだけ表示を変更したい、などといった場合です。こういったケースでは、経験上まずはユニットテストを書く -> テストを通る状態にする -> 必要な部分に処理を繋ぎこむ、といった手順で開発を行った方が、実装自体もテストを書かない場合よりも速くできることが多いです(いちいち端末の時間をいじるのも確認のコストが大きいので)。
というわけで、普段ユニットテストを書いてないという案件でお仕事されてる方も、こういう処理を見かけたら今回ご紹介するTimecopや他のライブラリを利用してユニットテストを始めてみる!というというのも良いかもしれません。
Timecopのご紹介
Timecopは、時間に関するテストやデバッグを簡単に行うことを目的として作られたrubyのgemです。このライブラリを利用することにより、ユニットテスト環境やデバッグ時に、「時を移動」「時を止める」「時の加速」といったことができるようになります。スタンド能力みたいですね。また、ブロック内でのみ時間操作を行うことができたり、ブロックを利用した呼び出し以外を禁止するセーフモードが存在するなど、機能面でも素晴らしいのですが、インターフェイスも良さがあります。
# 3日前に移動する
Timecop.travel(3.days.ago)
travelってメソッドで時間を移動しちゃうとか、良いですね。 が、残念ながらiOSにはTimecopがありませんでした。。。そこで、iOSでも時間旅行をしたいんだ!というモチベーションの元、この度iOS版Timecopを作成しました!現状ではNSDate周りのみのサポートですが、自分で使っている感じではこれでも十分実用できています。今一度端的に説明すると、Timecopはユニットテストのために時間に関連するクラスやメソッド(iOSではNSDate)が返す値を任意のものに変更することが出来るライブラリです。
iOS版 Timecopをインストールする
iOS版 TimecopはPodsに以下の行を追加することでインストールすることができます。
pod 'Timecop'
Podsを利用していない場合や、利用できないという環境の場合は下記のリポジトリから直接ソースを落としてご利用下さい。
https://github.com/kazu0620/ios-timecop
それでは詳しい使い方、使いどころについて次項から説明して行きます。
Timecopで時を進める
Timecopでは、[Timecop travelWithDate:(任意の時間のDateオブジェクト)]というメソッドを叩くことで、好きな時間に旅行することができます。下記は、シンプルなコード例です。
NSLog(@"current date :%@", [NSDate date]); // リアルな現在時刻
// 時よ進め!1時間進め!
NSDate *aHourLater = [NSDate dateWithTimeIntervalSinceNow:60*60];
[Timecop travelWithDate:aHourLater];
NSLog(@"current date :%@", [NSDate date]); // 1時間進んでる!!
[Timecop finishTravel]; // 時間旅行を終了する
NSLog(@"current date :%@", [NSDate date]); // リアルな現在時刻
さらにTravelを利用して、簡単なキャッシュ周りのテストを書く例を考えてみます。要件としては下記の様なものです。
- CacheManager的なクラスがある
- これにsetUsersメソッドを利用してusers情報をキャッシュする
- キャッシュしたusersの情報は1時間で消滅する
消滅するんじゃなくて再取得しろとかいろいろ細かいツッコミどころはありますが、シンプルに説明する例としてこのような実装のXCTestを書く場合は、下記の様な記述になります。
- (void)testCacheControl {
// 期限を60分間とするキャッシュをクライアント側で保持する場合のテスト例
[cacheManager setUsers:users]; // キャッシュを保持する
NSTimeInterval minutes = 60; // 1分
// キャッシュが保持されたかどうかをテストする
XCTAssertNotNil([cacheManager cachedUsers], @"キャッシュされた直後は、データがキャッシュされている");
// 【重要】Timecopで今から59分後に時間旅行を行う!
[Timecop travelWithDate:[NSDate dateWithTimeIntervalSinceNow:59*minutes]];
// キャッシュ期限は60分間なのでまだこの時点ではキャッシュは残っている
XCTAssertNotNil([cacheManager cachedUsers], @"59分後にはキャッシュはまだ残ってる");
// 【重要】Timecopでさらに1分後に時間旅行を行う!
[Timecop travelWithDate:[NSDate dateWithTimeIntervalSinceNow:1*minutes]];
// キャッシュ保持期限が切れたので、nilが返ってくるf
XCTAssertNil([cacheManager cachedUsers], @"60分以上経過するとにキャッシュされたデータは、Expiredとなりnilが返ってくる");
[Timecop finishTravel]; // 時間旅行を終了する
}
注意点としては、時間を将来に進めた場合、dateWithTimeIntervalSinceNow自体も時間旅行後の値を返すので二回目は60分進めるのではなく1分だけ進めれば良いという点に気をつけて下さい。また、Timecopでの時間操作処理は全て、下記の様にBlocks内に記述して、影響範囲をblock内に収めることも可能です。
NSLog(@"current date :%@", [NSDate date]); // リアルな現在時刻
[Timecop travelWithDate:aHourLater block:^{
NSLog(@"current date :%@", [NSDate date]); // blockの中は1時間後の時刻
}];
NSLog(@"current date :%@", [NSDate date]); // blockの外はリアルな現在時刻
Safemodeで安全に時間旅行を楽しむ
本家と同じく、iOS版 Timecopではsafemodeもサポートしています。これは、有効にすることでブロック記法以外での時間操作を禁止する(例外を発生させる)というものです。[Timecop finishTravel];の書き漏れによって意図した範囲以外に影響が出てしまう、というパターンをこれにより防ぐことができます。
// セーフモードを有効にする
[Timecop setSafeMode:YES];
// blockを伴う記述は通常通り行うことが可能
[Timecop travelWithDate:aHourAgo block:^{
NSLog(@"current date :%@", [NSDate date]); // 現在時刻を表示
}];
// 下記はblockを伴わない記述なので、例外によって落ちる
[Timecop travelWithDate:aHourAgo];
Timecopで時を停止する
Timecopでは、[Timecop freezeWithDate:(任意の時間のDateオブジェクト)]というメソッドを叩くことで、指定した時間で時を止めることができます。ザ・ワールドですね。Travelと同じくシンプルなコード例を記述しておきます。
// 現在時刻([NSDate date])で時を止める
[Timecop freezeWithDate:[NSDate date]];
// 時間停止直後の時間
NSDate *freezedDate = [NSDate date];
// 3秒間sleepする
sleep(3.0);
// 時間が静止している
if( freezedDate == [NSDate date] ){
NSLog(@"TIME FREEZED!!");
}
[Timecop finishTravel]; // そして時は動き出す
もちろん、freeezeもtravelと同様にblockを用いて影響範囲を限定して記述することができます。
[Timecop freezeWithDate:[NSDate date] block:^{
NSLog(@"current date :%@", [NSDate date]); // 現在時刻を表示
sleep(3.0);
NSLog(@"current date :%@", [NSDate date]); // blockの中では時間は静止してるのでsleepしても動かない
}];
sleep(3.0);
NSLog(@"current date :%@", [NSDate date]); // blockの外では通常通りの時間が流れている
travelとの違い
travelとfreeezeの違いですが、文字通りfreeezeは時間を指定した時間で完全に停止するのに対し、下記の例の様にtravelは指定した時間にジャンプ後も時間は経過し続けるという点で異なっています。目的や用途に応じて、適宜必要なものをご利用下さい。
NSDate *aHourAgo = [NSDate dateWithTimeIntervalSinceNow:60*60*-1];
[Timecop freezeWithDate:aHourAgo];
sleep(10)
NSLog(@"current date :%@", [NSDate date]); // 時間はピッタリ1時間前から進んでいない
[Timecop finishTravel]; // 一旦時間旅行を終了
[Timecop travelWithDate:aHourAgo];
sleep(10)
NSLog(@"current date :%@", [NSDate date]); // 時間はピッタリ1時間前から10秒間進んでいる
Timecopで時を加速する
テストを書くという目的ではあまり利用しないかもしれませんが、本家に揃えてiOS版 Timecopではscaleもサポートしています。これは、時間の進む速度を変更するためのメソッドです。どう動くかについては、下記の例をご覧いただくのがわかりやすいかと思います。スタンドでいうと、ゴールドエクスペリエンスやキングクリムゾンに近い振る舞いですね。
// 時間の進行速度を3600倍に変更する
[Timecop scaleWithFactor:3600];
// 現在時刻を出力
NSLog(@"current date :%@", [NSDate date]);
// 1秒間だけsleepする
sleep(1);
// 現在時刻を出力(1時間経過してる!)
NSLog(@"current date :%@", [NSDate date]);
こちらも、紹介は省きますがblocksでの記述をサポートしています。また、注意点として現状ではNSDateのみのサポートとなるため、NSTimerなどの動きをscaleで制御することは出来ません。こちら、必要となる状況が出たら追加しようとは考えてます。
おわりに
以上、時間についてのユニットテストを便利にするライブラリ、Timecopの紹介でした。使いづらい点や問題などあれば可能な範囲で対応したいと考えておりますので、GithubのIssueでも良いし、直接連絡くれてもOKなのでお気軽に連絡下さい。それではみなさま、良きテストライフを!