FlutterのWidgetテスト入門(最初の1本) でテストの足場を作り、時刻に依存する業務ロジックをテストし始めると、すぐに壁に当たります。「今日の業務日はどこか」を確かめたくて時計を固定すると、同じ時計を見ている「直前の操作からの経過時間(クールダウン)」まで止まってしまい、片方を固定するともう片方が試せません。原因は、性質の違う時間判定を 1 つの時計で兼ねていることにあります。この記事では AppClock を用途別に分けて注入し、業務時刻を固定したテストでもクールダウンだけを独立に進められる形を、最小構成の Flutter アプリとして作ります。
題材は業務端末でよくある「操作の受付ゲート」です。受付のたびに業務日を記録しつつ、短時間の二重受付はクールダウンで弾きます。
1. ゴールと非対象
対象読者
- Widget Test か unit test を一度は書いて、
flutter testを回した経験がある人 - 時刻に依存する判定をテストしようとして、時計を固定したら別の判定まで動かなくなった人
DateTime.now()を直接呼ぶコードを、テストしやすく直したい人
この記事で到達する状態
- 時刻を読む口を
AppClockという 1 つの抽象にまとめられる - 業務時刻用とクールダウン用で別インスタンスの時計を注入できる
- 業務時刻を固定したまま、クールダウン時刻だけを進めるテストを書ける
- 本番では両方に実時計を入れ、テストでは用途ごとに別の偽時計を入れる、という使い分けを説明できる
非対象
今回は時計の「分け方」に集中します。扱わないテーマは次のとおりです。
clockパッケージなど既存ライブラリの導入- タイムゾーンや夏時間の厳密な変換
- Riverpod や get_it での DI 配線の詳説(注入する場所の例だけ示します)
- 再試行バックオフそのものの実装(同じ考え方で時計を分ける、という指針だけ触れます)
2. なぜ時計を 1 つにすると詰まるのか
受付ゲートは、1 回の accept() で 2 種類の時間判定をします。
- 業務日の判定: いまが「どの業務日」かを決める。深夜 0 時ではなく朝 5 時で日付が切り替わる運用は珍しくない。
- クールダウンの判定: 直前の受付から十分な時間が経ったか。経過時間だけが問題で、暦の上の日付は関係しない。
この 2 つを 1 つの時計でまかなうと、テストで身動きが取れなくなります。たとえば、すべて同じ clock から計算する作りが次の例です。
// 悪い例: 1 つの時計を業務日にもクールダウンにも使い回す
class NaiveGate {
NaiveGate(this._clock);
final AppClock _clock;
DateTime? _lastAcceptedAt;
AcceptResult accept() {
final DateTime now = _clock.now();
// 業務日もクールダウンも、同じ now から計算してしまう
...
}
}
NaiveGate(FakeClock(02:00)) のように時計を 02:00 に固定すると、業務日が前営業日になることは確かめられます。ところが固定したままでは now.difference(last) が常に 0 のままで、クールダウンが切れる経路に到達できないわけです。逆にクールダウンの満了を試そうと時計を進めると、業務時刻まで一緒に動き、朝 5 時の境界をまたいで業務日が変わります。時計を固定するとクールダウンが試せず、時計を進めると業務日のほうが変わってしまう、という板挟みです。
詰まりの正体は「経過時間を測る時計」と「暦の日付を決める時計」を同一視している点にあります。そこで採るのが、時計を用途で分けて注入する方針です。
3. 先に全体像をつかむ
受付ゲートは、業務日には businessClock、クールダウンには cooldownClock と、用途で時計を呼び分けます。
flowchart TD
A[受け付ける] --> G[OperationGate.accept]
G -->|経過時間を測る| C[cooldownClock.now]
G -->|業務日を決める| B[businessClock.now]
C --> R{クールダウン中か}
R -->|はい| X[却下]
R -->|いいえ| Y[受理して業務日を記録]
B --> Y
押さえる点は次の 2 つです。
- 本番では
businessClockもcooldownClockも同じSystemClock(実時計)を入れる - テストでは用途ごとに別の
FakeClockを入れ、片方を固定したままもう片方を進める
同じインターフェースの時計を 2 つ受け取るだけで、本番のふるまいは変えずにテストの自由度だけが上がります。
4. プロジェクトを作成する
4-1. 環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
4-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create clock_injection
cd clock_injection
以降のコマンドとファイル操作は、このプロジェクト直下で行います。追加パッケージはありません。あとで flutter test を実行するため、DartPad ではなく実プロジェクトで進めます。
4-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
5. lib/main.dart を書き換えて時計を 2 つの用途に分ける
flutter create が生成した lib/main.dart を、次の内容で書き換えます。内容は、時刻の抽象 AppClock、実時計 SystemClock、受付ゲート OperationGate、結果を見せる最小 UI までを 1 ファイルにまとめたものです。
import 'package:flutter/material.dart';
void main() {
runApp(const ClockInjectionDemoApp());
}
/// アプリ内で時刻を読む口を 1 つに絞る抽象。
/// 用途ごとに別インスタンスを注入できるよう、メソッドは `now()` だけにする。
abstract interface class AppClock {
DateTime now();
}
/// 本番で使う実時計。業務時刻・クールダウンとも、本番ではこれを注入する。
class SystemClock implements AppClock {
const SystemClock();
@override
DateTime now() => DateTime.now();
}
/// 受付を却下する理由。
enum RejectReason {
withinCooldown('クールダウン中');
const RejectReason(this.label);
final String label;
}
/// 受付の判定結果。受理なら業務日を持ち、却下なら理由を持つ。
final class AcceptResult {
const AcceptResult.accepted({required this.businessDate})
: isAccepted = true,
rejectReason = null;
const AcceptResult.rejected(this.rejectReason)
: isAccepted = false,
businessDate = null;
final bool isAccepted;
final DateTime? businessDate;
final RejectReason? rejectReason;
}
/// 操作の受付ゲート。時刻を 2 つの用途で使う。
/// - businessClock: どの業務日として記録するかを決める「業務時刻」
/// - cooldownClock: 直前の受付からの経過時間を測る「クールダウン時刻」
///
/// この 2 つを別インスタンスで受け取るのが肝心な点。業務時刻を固定したテストでも、
/// クールダウン時刻だけを進められる。
class OperationGate {
OperationGate({
required AppClock businessClock,
required AppClock cooldownClock,
this.cooldown = const Duration(seconds: 3),
this.businessDayStartHour = 5,
}) : _businessClock = businessClock,
_cooldownClock = cooldownClock;
final AppClock _businessClock;
final AppClock _cooldownClock;
final Duration cooldown;
final int businessDayStartHour;
DateTime? _lastAcceptedAt;
AcceptResult accept() {
final DateTime elapsedNow = _cooldownClock.now();
final DateTime? last = _lastAcceptedAt;
if (last != null && elapsedNow.difference(last) < cooldown) {
return const AcceptResult.rejected(RejectReason.withinCooldown);
}
_lastAcceptedAt = elapsedNow;
return AcceptResult.accepted(
businessDate: _businessDateOf(_businessClock.now()),
);
}
/// 業務日は深夜 0 時ではなく businessDayStartHour で切り替える。
/// 例: 開始 5 時なら、02:00 の操作は前営業日として扱う。
DateTime _businessDateOf(DateTime moment) {
final DateTime shifted = moment.subtract(
Duration(hours: businessDayStartHour),
);
return DateTime(shifted.year, shifted.month, shifted.day);
}
}
class ClockInjectionDemoApp extends StatelessWidget {
const ClockInjectionDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Clock Injection Demo',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
home: const OperationGatePage(),
);
}
}
class OperationGatePage extends StatefulWidget {
const OperationGatePage({super.key});
@override
State<OperationGatePage> createState() => _OperationGatePageState();
}
class _OperationGatePageState extends State<OperationGatePage> {
// 本番は業務時刻・クールダウンの両方に実時計を注入する。
final OperationGate _gate = OperationGate(
businessClock: const SystemClock(),
cooldownClock: const SystemClock(),
);
AcceptResult? _lastResult;
int _acceptedCount = 0;
void _onPressed() {
final AcceptResult result = _gate.accept();
setState(() {
_lastResult = result;
if (result.isAccepted) {
_acceptedCount++;
}
});
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final AcceptResult? result = _lastResult;
return Scaffold(
appBar: AppBar(title: const Text('操作の受付ゲート')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
'クールダウンは 3 秒です。連続して押すと、3 秒以内の 2 回目以降は却下されます。',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _onPressed,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text('受け付ける'),
),
),
const SizedBox(height: 24),
if (result == null)
Text('まだ受け付けていません。', style: theme.textTheme.bodyMedium)
else if (result.isAccepted)
_ResultCard(
color: theme.colorScheme.primaryContainer,
title: '受理しました',
message: '業務日: ${_formatDate(result.businessDate!)}',
)
else
_ResultCard(
color: theme.colorScheme.errorContainer,
title: '却下しました',
message: '理由: ${result.rejectReason!.label}',
),
const SizedBox(height: 16),
Text(
'受理した回数: $_acceptedCount',
style: theme.textTheme.titleMedium,
),
],
),
),
);
}
String _formatDate(DateTime date) {
final String month = date.month.toString().padLeft(2, '0');
final String day = date.day.toString().padLeft(2, '0');
return '${date.year}-$month-$day';
}
}
class _ResultCard extends StatelessWidget {
const _ResultCard({
required this.color,
required this.title,
required this.message,
});
final Color color;
final String title;
final String message;
@override
Widget build(BuildContext context) {
return Card(
color: color,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(message),
],
),
),
);
}
}
flutter run で起動すると、まだ一度も受け付けていない状態から始まります。
「受け付ける」を押すと、業務日付きで受理されます。表示される業務日は、実行した時刻を朝 5 時基準で切り替えた結果です。深夜 0 時から朝 5 時までに押せば、前日の日付になります。
3 秒以内にもう一度押すと、クールダウン中として却下されます。ここで見ている経過時間は cooldownClock のほうで、業務日を決めた businessClock とは別インスタンスです。
コードのポイント
① 時刻を読む口を AppClock 1 つに絞る
abstract interface class AppClock {
DateTime now();
}
class SystemClock implements AppClock {
const SystemClock();
@override
DateTime now() => DateTime.now();
}
時刻の入口を now() だけの抽象にまとめます。DateTime.now() を業務ロジックの中で直接呼ばず、必ず AppClock 越しに読む形へ寄せておくと、後からの差し替えが自由です。abstract interface class にすることで、実装専用(implements 前提)であることも示せます。
② 業務時刻とクールダウンを別インスタンスで受け取る
OperationGate({
required AppClock businessClock,
required AppClock cooldownClock,
this.cooldown = const Duration(seconds: 3),
this.businessDayStartHour = 5,
}) : _businessClock = businessClock,
_cooldownClock = cooldownClock;
OperationGate は同じ AppClock 型を 2 つ受け取ります。型は同じでも役割が違うので、フィールドを分けて持ちます。本番ではどちらにも SystemClock を渡すので、ふるまいは 1 つの時計のときと同じです。違いはテストのときだけ現れます。
③ クールダウンは cooldownClock、業務日は businessClock で読む
AcceptResult accept() {
final DateTime elapsedNow = _cooldownClock.now();
final DateTime? last = _lastAcceptedAt;
if (last != null && elapsedNow.difference(last) < cooldown) {
return const AcceptResult.rejected(RejectReason.withinCooldown);
}
_lastAcceptedAt = elapsedNow;
return AcceptResult.accepted(
businessDate: _businessDateOf(_businessClock.now()),
);
}
経過時間は cooldownClock から、業務日は businessClock から読む形です。どの判定がどの時計を見ているかが行ごとに対応するので、テストで片方だけ動かしたときの結果も追いやすくなります。
6. テストで「業務時刻を固定したままクールダウンだけ進める」
lib/main.dart を書き換えたことで、flutter create 直後に入っている test/widget_test.dart は元のカウンターアプリ前提のままになり、失敗します。残したまま flutter test を実行したときの出力がこれです。
test/widget_test.dart:16:35: Error: Couldn't find constructor 'MyApp'.
await tester.pumpWidget(const MyApp());
^^^^^
生成されたテストが組み立てるのは MyApp で、書き換え後の lib/main.dart にその名前のクラスは無い状態です。テストが走る前のコンパイルで止まるため、結果は Some tests failed. だけになります。この記事では受付ゲートのテストに絞って進めるため、test/widget_test.dart は削除してかまいません。
ここが時計を分けた効果の出る場所です。test/operation_gate_test.dart を次の内容で作成します。
import 'package:clock_injection/main.dart';
import 'package:flutter_test/flutter_test.dart';
/// テスト用の操作できる時計。値を固定したり、任意に進めたりできる。
class FakeClock implements AppClock {
FakeClock(this._value);
DateTime _value;
@override
DateTime now() => _value;
void advance(Duration duration) => _value = _value.add(duration);
}
void main() {
group('OperationGate の業務日判定', () {
test('開始 5 時境界より前の操作は前営業日として扱う', () {
// 02:00 は業務日の開始(5 時)前なので前日扱い。
final FakeClock business = FakeClock(DateTime(2026, 1, 15, 2));
final FakeClock cooldown = FakeClock(DateTime(2026, 1, 15, 2));
final OperationGate gate = OperationGate(
businessClock: business,
cooldownClock: cooldown,
);
final AcceptResult result = gate.accept();
expect(result.isAccepted, isTrue);
expect(result.businessDate, DateTime(2026, 1, 14));
});
test('開始 5 時以降の操作は当日として扱う', () {
final FakeClock business = FakeClock(DateTime(2026, 1, 15, 9));
final FakeClock cooldown = FakeClock(DateTime(2026, 1, 15, 9));
final OperationGate gate = OperationGate(
businessClock: business,
cooldownClock: cooldown,
);
expect(gate.accept().businessDate, DateTime(2026, 1, 15));
});
});
group('OperationGate のクールダウン', () {
test('業務時刻を固定したままでもクールダウンは独立に進む', () {
// 業務時刻は 02:00 に固定し、前営業日のまま動かさない。
final FakeClock business = FakeClock(DateTime(2026, 1, 15, 2));
// クールダウン時刻だけを別インスタンスで進める。
final FakeClock cooldown = FakeClock(DateTime(2026, 1, 15, 2));
final OperationGate gate = OperationGate(
businessClock: business,
cooldownClock: cooldown,
cooldown: const Duration(seconds: 3),
);
// 1 回目は受理。
expect(gate.accept().isAccepted, isTrue);
// 1 秒後はクールダウン中なので却下。
cooldown.advance(const Duration(seconds: 1));
final AcceptResult second = gate.accept();
expect(second.isAccepted, isFalse);
expect(second.rejectReason, RejectReason.withinCooldown);
// さらに進めて合計 4 秒。クールダウンを抜けて受理。
cooldown.advance(const Duration(seconds: 3));
final AcceptResult third = gate.accept();
expect(third.isAccepted, isTrue);
// 業務時刻は動かしていないので、業務日は前営業日のまま。
expect(third.businessDate, DateTime(2026, 1, 14));
});
});
}
3 つ目のテストが、この設計の狙いそのものです。business は 02:00 に固定したまま一度も動かさず、cooldown だけを合計 4 秒進めています。これで「受理 → クールダウン中で却下 → 経過後に再び受理」の流れを確かめられます。しかも最後の受理でも businessDate は前営業日の 2026-01-14 のままです。業務時刻の時計に触れていないので、業務日は揺れません。時計を 1 つに統一していたら、このテストは成り立たないわけです。
flutter test を実行すると、3 件とも通ります。
00:00 +0: OperationGate の業務日判定 開始 5 時境界より前の操作は前営業日として扱う
00:00 +1: OperationGate の業務日判定 開始 5 時以降の操作は当日として扱う
00:00 +2: OperationGate のクールダウン 業務時刻を固定したままでもクールダウンは独立に進む
00:00 +3: All tests passed!
FakeClock は now() を返すだけの小さなクラスで、advance() で値を進めます。テストごとに業務用とクールダウン用を別々に用意し、片方を固定したまま片方を進められるところが、用途分割の恩恵です。
7. 本番の配線と「判定だけ実時計を使う」場面
本番のウィジェットでは、両方に実時計を入れます。
final OperationGate _gate = OperationGate(
businessClock: const SystemClock(),
cooldownClock: const SystemClock(),
);
本番は 1 つの時計を共有していたときと同じ動きです。分割の代償は「SystemClock を 2 回書く」だけで、実行時のふるまいは変わりません。Riverpod などで注入する場合も、合成する場所が Provider に移るだけで考え方は同じです(Riverpodで状態管理を始める入門 の Provider に OperationGate を載せ替えられます)。
用途を分けておくと、テストの混ぜ方も選べます。
- 業務日のロジックだけ確かめたいとき: 業務時刻を
FakeClockで固定し、クールダウンは結果に影響しない範囲で固定する - クールダウンだけ確かめたいとき: 業務時刻は固定したまま、クールダウンの
FakeClockをadvance()で進める - 経過時間を実時間で見たい統合テスト: クールダウンにだけ
SystemClockを入れ、業務時刻は固定しておく
最後の形が「判定だけ実時計を使う」場面です。業務日は再現性のために固定したいが、経過時間は実際の Future.delayed と合わせて確かめたい、というときに選べます。再試行のバックオフ時計を足すときも同じ考え方です。業務時刻やクールダウンとは別の 3 つ目の AppClock として分けておくと、テストが互いに干渉しません。
8. まとめ
時刻に依存する判定が 2 つ以上あるなら、時計は用途で分けて注入します。
- 経過時間を測る時計と、暦の日付を決める時計は性質が違う
- 1 つに統一すると、片方を固定したテストでもう片方が動かせず詰まる
AppClockを用途別に注入すれば、本番の動きを変えずにテストの自由度だけが上がる
手元のコードで DateTime.now() を直接呼んでいる判定を 1 つ探し、それが他の時間判定と混ざっていないか見てください。混ざっているなら、AppClock を 2 つに割るところから始めると無理なく取り入れられます。テストの足場づくりは FlutterのWidgetテスト入門(最初の1本) を、結果の型を増やすときの分岐整理は Dart 3のsealed classとパターンマッチングで分岐を安全に書く を合わせて読むと、判定とテストの両輪を整えやすくなります。