MimiCatLab
← ホームに戻る
Unity

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、テスト用は McManualClockAdvanceSet)である。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回

Every(1分)=1,440回、Every(1時間)=24回、Delay(30秒) の再登録連鎖=2,880回、RunAt(翌0時)=1回。毎時のヘルスが全モジュール HealthyPendingCount が初期化直後と同じ。

Scheduler 7日間

1分刻み

Every(1時間)=168回、Every(1日)=7回、RunAt(毎日09:00) 連鎖=7回

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)モジュールで書く必要があった。

教訓

  1. 決定論が長時間テストを現実的にする。 仮想時計と Tick 駆動スケジューラーという土台を先に整えた投資が効いた。
  2. 理論値を先に計算して突き合わせる。 1,440 回・86,400 回・72 件のような期待値があるから、1 回のずれや漏れが見える。
  3. メモリ増分も不変条件に含める。 「動き続ける」だけでは肥大を見逃す。
  4. 共有ハーネスは作らない。 テスト Assembly 間の参照を増やさず、各パッケージ内で完結させる。
  5. 命名で後から絞り込めるようにする。 Mc<パッケージ>SoakTests、テスト名は 対象_期間_不変条件 に統一した。

対象外

接続系(Remote・OBS・TTS・YouTube・Discord)の再試行ループはスレッドプールの Task.Delay で動くので仮想時計では進められず、対象外にした。ここは実時間の監視(Diagnostics のヘルス報告と Discord の日次サマリー)に頼る。仮想時計のテストは GC やフレーム落ち、外部 I/O のような実時間特有の問題を代替しない。

Related Articles