24時間分のソークテストを 0.15 秒で回す:仮想時計と Tick 駆動スケジューラー
日付境界・繰り返し予定・クールダウン辞書の肥大は数分のテストでは見つからない。時計を注入し、理論値と突き合わせる EditMode ソークテストの作り方。
何に困っていたか
MimiCatHub は 24 時間の無人配信で動く。数分で終わるユニットテストは通るのに、日付をまたいだ集計がずれる、繰り返し予定が少しずつ遅れる、クールダウン用の辞書が肥大する、といった「時間が経って初めて壊れるもの」が検出できなかった。
実時間で 24 時間回すテストは CI に載らない。かといって載せないと壊れたまま出荷する。
土台:時計・乱数・スケジューラーを注入する
先に Core へ次の抽象を入れた。
public interface IMcClock
{
DateTimeOffset UtcNow { get; }
DateTimeOffset Now { get; }
double RealtimeSeconds { get; }
}
public interface IMcScheduler
{
McScheduleHandle Delay(TimeSpan delay, Action pAction); // 1回
McScheduleHandle Every(TimeSpan interval, Action pAction); // 繰り返し
McScheduleHandle RunAt(DateTimeOffset at, Action pAction); // 指定時刻
Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken = default);
int PendingCount { get; }
}
既定実装は実時間の McSystemClock、テスト用は McManualClock(Advance と Set)である。McContext.Clock / Random / Scheduler で全モジュールへ配り、モジュール側は DateTimeOffset.UtcNow を直接呼ばない。
肝はスケジューラーの実行タイミングである。スケジューラーは McRuntime.Tick の中でメインスレッド実行され、期限は IMcClock.UtcNow で判定する。だから McManualClock.Advance してから Runtime.Tick を呼べば、予定が決定的に進む。スレッドプールの Task.Delay を使わないので、実時間に依存しない。
ソークテストの形
テストは EditMode で Packages/*/Tests/Editor に置く。PlayMode にはしない。実時間依存ではないので、CI で毎回回せる。
var clock = new McManualClock(new DateTimeOffset(2026, 9, 11, 0, 0, 0, TimeSpan.Zero));
var runtime = new McBuilder().SetClock(clock)./* モジュール登録 */.Build();
await runtime.InitializeAsync();
for (int i = 0; i < 86400; i++)
{
clock.Advance(TimeSpan.FromSeconds(1));
runtime.Tick();
}
検証するのは「動き続けた」ではなく不変条件と理論値である。
テスト | 期間と刻み | 確認する理論値 |
|---|---|---|
Runtime 24時間 | 1秒刻み、Tick 86,400回 |
|
Scheduler 7日間 | 1分刻み |
|
Analytics 8日間 | 1時間刻み | 日次ローテーション 8回、週次(ISO 週、月曜 0 時)1回、Total=192 |
ChatCommand 24時間 | 5人が10秒ごとに投稿、クールダウン60秒 | 成立=1人1,440件、拒否=合計36,000件 |
Diagnostics 24時間 | 毎時前半 10fps・後半 50fps | 超過警告 3件×24=72件、回復 24件 |
メモリも不変条件に入れた。1 時間の暖機後から 24 時間後までの確保メモリ増分を 16MB 未満とする。クールダウン辞書の肥大のようなリークを数で捕まえるためである。
結果は 10 件すべて成功。最も遅い 2 時間分の DelayAsync ループで約 1.2 秒、24 時間を 1 秒刻みで回す Runtime テストは約 0.15 秒だった。
実装して初めて分かったこと
TimeSpan の丸めが長時間で効く。 TimeSpan.FromSeconds(1f / 60f) は 17ms へ丸められ、1 時間で 36 秒ずれる。フレーム時間は 0.1 秒や 0.02 秒のように割り切れる値を使う。Diagnostics のテストが 50fps なのはこのためである。
Every の期限の決め方。 次の期限は「前回の期限 + interval」で決めるが、それが既に過去なら now + interval にして連続実行を避ける。刻みが粗くても取りこぼさないのは、期限判定が <= now だからである。
必須モジュールは再起動できない。 稼働中の再起動後も予定が続くことを確認するテストは、任意(Optional)モジュールで書く必要があった。
教訓
- 決定論が長時間テストを現実的にする。 仮想時計と Tick 駆動スケジューラーという土台を先に整えた投資が効いた。
- 理論値を先に計算して突き合わせる。 1,440 回・86,400 回・72 件のような期待値があるから、1 回のずれや漏れが見える。
- メモリ増分も不変条件に含める。 「動き続ける」だけでは肥大を見逃す。
- 共有ハーネスは作らない。 テスト Assembly 間の参照を増やさず、各パッケージ内で完結させる。
- 命名で後から絞り込めるようにする。
Mc<パッケージ>SoakTests、テスト名は対象_期間_不変条件に統一した。
対象外
接続系(Remote・OBS・TTS・YouTube・Discord)の再試行ループはスレッドプールの Task.Delay で動くので仮想時計では進められず、対象外にした。ここは実時間の監視(Diagnostics のヘルス報告と Discord の日次サマリー)に頼る。仮想時計のテストは GC やフレーム落ち、外部 I/O のような実時間特有の問題を代替しない。
Related Articles