2021/08/07追記
改めて見直してみると全体的にわかりにくかったので見直しました。
テスト時の時間を指定したい!
Rustを愛する全国のみなさんこんにちは。
Rustでは、もはやテストを書かない時代は過ぎ去りました。
コンパイル時チェックやテストは多くのバグを潰してくれます。
ところで、テストをする際に困るのは副作用です。
特に現在時刻に依存するコードはテストに困ります。
(ごたくはいいからコードが見たい!って方は一番下にどうぞ)
テストするコード
今回テストしたいのはこんなコードです。
現在時刻が0時から8時なら 「おはようございます」
9時から24時までなら「おそようございます」とあいさつするコードです
use chrono::{FixedOffset, Timelike, Utc};
// UTC +09:00なDateTimeを返す
fn なう() -> DateTime<FiexedOffset> {
Utc::now().with_timezone(&FixedOffset::east(9 * 3600))
}
fn ohayo() -> String {
match なう().hour() {
0..=8 => String::from("おはようございます"),
9..=23 => String::from("おそようございます"),
_ => panic!("ここは通らないはず"),
}
}
さあ、コードを書いたのでテストも書きましょう。
あまり意味のないテストコード
なう()の値が実行時依存なので、普通にテストを書くとこうなります
#[cfg(test)]
mod tests {
use super::*;
...
#[test]
fn test() {
match なう().hour() {
0..=8 => assert_eq!(ohayo(), String::from("おはようございます")),
9..=23 => assert_eq!(ohayo(), String::from("おそようございます")),
_ => panic!("そもそもここを通るのはおかしい"),
};
}
}
これではいけません。
今がお昼過ぎだとしたら、「おはようございます」のテストをするために明日まで待つか、システムクロックをいじらないといけません。
テスト時のみモックを導入する
DIパターンは使いづらい
さて、テストの際にはモックを使うことが多いですが、
実装を切り替える可能性のあるDB周りのコードなどと違い、
通常のコードにはできるだけ手を加えたくありません。
// トレイトを作って、、、
pub trait Now {
fn now(): DateTime<FixedOffset>;
}
// 関数を実装切り替えできるようにして、、、
fn ohayo<N: Now>() -> String {
match N::now().hour() { ... }
}
// 通常時の処理を実装して、、、
pub struct MyDateTime {};
impl Now for MyDateTime() -> DateTime<FixedOffset> {
// 現在時刻のタイムゾーン付きを返す
}
// テスト用の実装を、、、?
pub struct MockDateTime {};
impl Now for MockDateTime() -> DateTime<FixedOffset> {
// 5時を返す
}
#[test]
fn test() {
// 5時
assert_eq!(ohayo::<MockDateTime>(), String::from("おはようございます"))
// 12時で試したい。。あれ??
// というか通常コードに ohayo::<MyDateTime>() とかやりたくない。。
}
じゃあ関数のパラメータで好きな時間を指定できるようにでもするか。。?
LL系の言語やデフォルト引数が使える言語ならこれでも良いですが、
やっぱり実行時分岐と通常時のコードにテスト用の機能とか入れたくないですよね。
use chrono::{FixedOffset, Timelike, Utc};
// UTC +09:00なDateTimeを返す
fn なう(n: Option<DateTime>) -> DateTime<FiexedOffset> {
match n {
Some(dt) => dt,
None => Utc::now().with_timezone(&FixedOffset::east(9 * 3600)),
}
}
fn ohayo() -> String {
match なう(None).hour() {
// ↑通常コードにこんなのかきくない。。。
0..=8 => String::from("おはようございます"),
9..=23 => String::from("おそようございます"),
_ => panic!("ここは通らないはず"),
}
}
ビルド時になう()の実装を差し替えたい!
C++だったらマクロで、goだったらビルド時のtagsで実装を切り替えることができます。
ではrustでできないのでしょうか?
rustで実装する
まず、なう()をユーティリティモジュールに切り出し、
通常コンパイル時には通常の実装を。
テスト時にはスレッドローカルなグローバル変数に現在時刻を仕込んで置けるようにします。
rustの場合はグローバル変数をうかつに書けない(unsafeで囲ったりしないといけない)
のでなんか色々します。
こちらのページを参考にさせていただきました。
https://blog.iany.me/2019/03/how-to-mock-time-in-rust-tests-and-cargo-gotchas-we-met/
結論
今までの流れをコードにすると、モジュールと、アプリケーションコードと、テスト部分で以下のようになります。
通常ビルド時は now() が使われ、テスト時はmock_time::now()が使われます。
モジュール部分
テスト時と通常時の二つ実装します。
テスト時の #[cfg(test)]
はよく見かけますが、通常時は#[cfg(not(test))]
が使えます。
use chrono::{DateTime, Datelike, FixedOffset, Utc};
use std::thread;
// 通常時のコード +9:00のタイムゾーン指定付き
#[cfg(not(test))]
pub fn now() -> DateTime<FixedOffset> {
Utc::now().with_timezone(&FixedOffset::east(9 * 3600))
}
#[cfg(test)]
pub mod mock_time {
use super::*;
use std::cell::RefCell;
thread_local! {
static MOCK_TIME: RefCell<Option<DateTime<FixedOffset>>> = RefCell::new(None);
}
pub fn now() -> DateTime<FixedOffset> {
MOCK_TIME.with(|cell| {
cell.borrow()
.as_ref()
.cloned()
.unwrap_or_else(|| Utc::now().with_timezone(&FixedOffset::east(9 * 3600)))
})
}
pub fn set_mock_time(time: DateTime<FixedOffset>) {
MOCK_TIME.with(|cell| *cell.borrow_mut() = Some(time));
}
pub fn clear_mock_time() {
MOCK_TIME.with(|cell| *cell.borrow_mut() = None);
}
}
#[cfg(test)]
pub use mock_time::now;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn now_ok() {
let dt = String::from("2020-02-01T00:00:00+09:00");
mock_time::set_mock_time(DateTime::parse_from_rfc3339(dt.as_str()).unwrap());
assert_eq!(now().to_rfc3339(), dt);
}
}
アプリケーションコード
上記のモジュールを使うだけで、特に意識することなく now()
を使えます
use crate::util::datetime::now;
fn ohayo() -> String {
match now().hour() {
0..=8 => String::from("おはようございます"),
9..=23 => String::from("おそようございます"),
_ => panic!("ここは通らないはず"),
}
}
テストコード
上記のテストコードもさくっとかけます。
... // 上のコードの続き
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Datelike};
use crate::util::datetime::mock_time;
#[test]
fn good_morning() {
// 朝8時です!
mock_time::set_mock_time(
DateTime::parse_from_rfc3339("2020-01-01T08:00:00+09:00").unwrap(),
);
assert_eq!(ohayo(), "おはようございます");
// 昼12時です!
mock_time::set_mock_time(
DateTime::parse_from_rfc3339("2020-01-01T12:00:00+09:00").unwrap(),
);
assert_eq!(ohayo(), "おそようございます");
}
}
いやー、コンパイル時分岐って素晴らしいですね!