testing-strategy について
Flutter と Dart のチーム向けテスト戦略スキルです。コード層と振る舞いに応じたテスト方法を扱い、決定的なテスト、インメモリデータベース、ヘッドレスの Riverpod テスト、手動確認の対象を示します。
主な機能
- 01適切な最小コストのテスト層を選択
- 02クロックを注入して固定し再現性を確保
- 03シード付きファジングと独立したオラクルを使用
- 04インメモリ Drift でデータ層をテスト
- 05ProviderContainer で Notifier を操作
- 06手動テストが必要な経路を記録
活用シーン
- 01ウィジェットを起動せずドメインロジックをテスト
- 02データベース制約やマイグレーションの検証
- 03時間依存の動作を再現可能な形でテスト
- 04デバイス固有動作の手動確認を計画
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill testing-strategyインストール後、Claude Code で/testing-strategyを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/allocate_property_test.dartin_memory_drift_test.dartnotifier_container_test.dartreferences/coverage-and-budget.mdproperty-and-fakes.mdtest-layers.mdscripts/check_test_hygiene.shrun_tests.sh詳細
テスト戦略
テストは、ビルド時に制御できる唯一の正しさの評価手段です。何十年も前からある比率に合わせるのではなく、コードに合わせてテストスイートを設計し、各テストではビルドが成功するか失敗するかを判定できる動作を必ず検証してください。これは test/ と integration_test/ 配下のすべてのテストに適用されます。
対象のタスクに関するリファレンスを確認してください。
references/test-layers.md— レイヤーごとのハーネス、インポート、後処理、境界ケースの表(純粋なコア、インメモリ Drift、ヘッドレス Notifier、統合テスト)。references/property-and-fakes.md— モックよりフェイクを優先する方針、列挙型に基づくフェイクの状態、シード付きファジング、独立したオラクル、特定の失敗クラスが存在しないことを検証するテスト。references/coverage-and-budget.md— ファイル単位の最低基準、カバレッジが上方に誤って偏る問題の修正、テストスイートの実行時間の予算、手動テストへの引き継ぎ。
PR の前に scripts/check_test_hygiene.sh と scripts/run_tests.sh を実行してください。
ゴールデン、RTL、a11y ウィジェットの仕組みについては widget-golden-and-a11y-testing を参照してください。このスキルは、ピクセルより下のすべてを対象とします。
譲れないルール
- テストスイートはピラミッドではなく、コードに合わせて構成します。 70/20/10 という数値は、2011 年のテストのサイズに関するヒューリスティックに由来し(提唱者自身が「適当に決めた」と述べています)、Flutter の unit/widget/integration の分類を示したものではありません。その振る舞いを検証できる最も低コストの層でテストします。
f(input) -> outputで表現できるものはすべてユニットテストまたはプロパティテストであり、pumpWidgetを使うものではありません。純粋なロジックをウィジェットツリー経由で実行すると、時間がかかり、不安定になり、どの層で問題が起きたのかが分かりにくくなります。 - ビジネスルールは Flutter に依存しないパッケージに置き、
Clockを注入します。 ドメイン計算は、Flutter/プラグイン/IO のインポートが一切ない純粋な Dart に置き、package:test(flutter_testではありません)でテストします。時間を表す型はpackage:clockのClockのみです。DateTime.now()は絶対に使わず、独自のClockServiceも作りません。純粋なcoreは環境のclock.now()を読み取り、テストではwithClock(Clock.fixed(t), …)で固定します。Riverpod/feature のコードは注入されたclockProviderを読み取り、clockProvider.overrideWithValue(Clock.fixed(t))でオーバーライドします(この接点はvalue-objects-money-and-unitsが所有します)。この禁止は構造によって徹底します。純粋なパッケージには Flutter SDK の制約を宣言しません。 - 例だけでなく、不変条件を検証します。 すべての変換に、往復テスト(
decode(encode(x)) == x)と、中間値/境界値での丸めのゴールデンテストを用意します。普遍的な主張はすべて、シード付きファズループ(for (var seed = 0; seed < N; seed++))またはプロパティテストで検証し、独立したオラクルと照合します。テスト対象の本番関数をオラクルにしてはいけません。生成した入力をreason:に出力し、失敗時にそれ自体が最小限の再現例となるようにします。整数は==のみを使い、倍精度浮動小数点数にはcloseTo(x, 1e-9)を使います。 - データ層は実際のインメモリエンジンを使ってテストします。
NativeDatabase.memory()を使い、DAO をモックしないでください。DAO をモックしても、SQL、制約、インデックス、マイグレーションについて何も検証できません。リポジトリをモックするのは、データ層より上の層に限ります。addTearDown(db.close)を使い、ストリームを同期的に閉じて、リアクティブな.watch()ストリームによる「Timer still pending」の漏れを防ぎます。 - 自分たちが所有するコードには、モックより単純な
implementsフェイクを優先します。class FakeX implements X(noSuchMethodを持つスーパークラスは使いません)にすると、インターフェースの変更がコンパイルエラーになります。状態をフィールドで表し、呼び出し順ではなくリスクをモデル化でき、契約のドキュメントとしても機能します。mocktailは本当に外部の依存関係に限って使います。コード生成が必要で、null safety の利点もないmockito/@GenerateMocksは決して使いません。 any()/captureAny()に渡すカスタム型ごとに、setUpAllでregisterFallbackValueを登録します。 mocktail はフォールバックがない場合、コンパイル時ではなく実行時に例外をスローします。これは mocktail で最もよくある失敗です。ProviderContainerを使って Notifier をヘッドレスで実行します。 プロバイダーの依存関係はoverrideWithでオーバーライドします。状態のテストにウィジェットをポンプしてはいけません。公開されているAsyncValue/状態を検証し、.notifier経由でアクションを実行し、ティアダウンでコンテナを破棄します。- エンドツーエンドの受け入れゲートを 1 つ設けます。 現実的なシナリオを 1 つ選び、期待される結果との完全一致と、保存不変条件(部分の合計が全体と等しいこと)を検証します。これは各要素が組み合わさって機能することを証明するテストです。プリミティブ自体に実行時アサーションの安全網(
assert(sum(result) == total))を設けます。リリースビルドではコストがなく、問題が起きた瞬間に検出できます。 - 復旧不能なバグにつながるファイルのカバレッジ下限は、カバレッジゲートではなく差分レビューで維持します。 自動化されたパーセンテージゲートはありません。カバレッジは公開レポートであり(
ci-pipeline-and-gatesが管理します)。その代わり、カバレッジの欠落が気付かれないデータ損失につながる少数のファイル(マイグレーション、金額/配分プリミティブ、ワイヤーフォーマットの落とし穴があるパーサー)は、100% の下限を維持します。これは行数を数えるのではなく、差分を読むことで徹底します。ディレクトリ単位や全体のパーセンテージゲートは、見栄えだけのテストや除外の増加を招きます。まず、カバレッジが実態より高く見える問題を修正します。flutter test --coverageはどのテストからもインポートされないファイルを含めないため、数値は安全性を実際以上に示します。 - 無期限のアニメーション(スプラッシュ、シマー、スピナー)に
pumpAndSettle()を使ってはいけません。 10 分のタイムアウトまでハングします。fakeAsyncと時間を指定したpump(Duration)を使います。 - テストスイートは高速に保ち、構造上テストできない経路は手動確認に委ねます。 数分かかるスイートは実行されなくなり、実行されないスイートは信用されなくなります。エミュレーターでは再現できないもの(実際の音声、OEM デバイスの多様性、Flutter エンジンのないネイティブサーフェス)は、テストで成功したように見せかけず、名前を付けたリリース前の手動確認項目として列挙します。何も証明しないグリーンテストよりも、未検証の箇所を認めるほうが適切です。
テストの対象をコードに合わせる(package:test と flutter_test)
純粋なロジックはウィジェットバインディングなしで dart test を使って実行し、UI とプラグインラッパーのテストにのみ flutter_test が必要です。
// test/domain/money_test.dart — pure Dart, `dart test`, no flutter_test import.
import 'package:test/test.dart';
import 'package:app_core/app_core.dart';
void main() {
// Round-trip: encoding is lossless across the ISO-4217 exponent range.
test('minor-units round-trip for exponents 0, 2, 3', () {
for (final currency in [Currency.jpy, Currency.usd, Currency.kwd]) {
final money = Money(amountMinor: 12345, currency: currency);
expect(Money.parse(money.format(), currency), equals(money));
}
});
}
クロックを注入し、テストでは固定する
本番コードでは clock.now() を読み取ります。テストでは時刻を固定することで、時間依存のロジックを決定論的にします。システム時刻に依存せず、不安定なテストも発生しません。
// Production: a Reminder is due when its target time has passed.
bool isDue(Reminder r) => !clock.now().isBefore(r.dueAt);
// test/domain/reminder_test.dart
test('reminder is due exactly at its due instant', () {
final due = DateTime.utc(2026, 1, 1, 9);
withClock(Clock.fixed(due), () {
expect(isDue(Reminder(dueAt: due)), isTrue);
});
});
プロバイダーの背後にあるコードでは、clockProvider を注入し、コンテナテストで clockProvider.overrideWithValue(Clock.fixed(due)) を使って固定します。同じ Clock、同じ固定時刻を、アンビエントゾーンではなく Riverpod 経由で使用します。
シード付きファジングと独立したオラクル
package:test を使った20行のコードで、古くなったプロパティテスト用パッケージと同等のことができます。生成された入力が組み込みの最小再現例になります。オラクルはテスト対象の関数から独立している必要があります。
// test/domain/allocate_test.dart
test('allocate: parts always sum to the input (fuzz)', () {
final rng = Random(0xC0FFEE);
for (var seed = 0; seed < 500; seed++) {
final n = rng.nextInt(20) + 1;
final total = rng.nextInt(100000);
final weights = List.generate(n, (_) => rng.nextInt(500));
final parts = allocate(amountMinor: total, weights: weights);
expect(parts.fold(0, (a, b) => a + b), equals(total),
reason: 'seed=$seed total=$total weights=$weights'); // its own repro
}
});
モック DAO ではなく、実際のインメモリ Drift を使う
// test/data/order_dao_test.dart
void main() {
late AppDatabase db;
setUp(() => db = AppDatabase(NativeDatabase.memory()));
tearDown(() => db.close()); // reactive .watch() streams must not leak timers
test('CHECK constraint rejects a negative quantity', () {
expect(
() => db.orderDao.insertItem(const OrderItem(quantity: -1)),
throwsA(isA<SqliteException>()), // proves the real constraint, not a stub
);
});
}
ProviderContainer を使ったヘッドレス Notifier テスト
// test/features/cart/cart_notifier_test.dart
void main() {
test('addItem publishes the new total', () async {
final container = ProviderContainer(
overrides: [orderRepositoryProvider.overrideWith((ref) => _FakeOrderRepo())],
);
addTearDown(container.dispose);
await container.read(cartNotifierProvider.future);
await container.read(cartNotifierProvider.notifier).addItem(_anItem);
final state = container.read(cartNotifierProvider);
expect(state, isA<AsyncData<CartState>>());
expect(state.requireValue.totalMinor, 1299);
});
}
ProviderContainer.test()(Riverpod 3.x)は自動で破棄され、利用可能な場合はこちらが推奨されます。それ以外の場合は addTearDown(container.dispose) を使います。
fakeAsync でタイマーを操作する
test('debounced search fires once after the window', () {
fakeAsync((async) {
final calls = <String>[];
final debouncer = Debouncer(const Duration(milliseconds: 300));
debouncer.run(() => calls.add('a'));
async.elapse(const Duration(milliseconds: 299));
expect(calls, isEmpty);
async.elapse(const Duration(milliseconds: 1));
expect(calls, ['a']);
});
});
アンチパターン
- ピラミッドを逆転させること — 純粋な
f(input) -> outputロジックをpumpWidget経由で実行することです。遅く、不安定になりやすく、問題のあるレイヤーが見えにくくなります。 - ドメインロジックから
DateTime.now()やアンビエントなRandom()にアクセスできること。 「同じ入力なら同じ出力」をテストできなくなります。Clockとシードを注入してください。 - 純粋なパッケージに
flutter_testをインポートすること。 構造上の境界を崩し、最速のテスト階層を遅くします。パッケージに Flutter SDK の制約を宣言しないでください。 - データベースの代わりにモック DAO を使うこと。 SQL、制約、インデックス、マイグレーションについて何も検証できません。
NativeDatabase.memory()を使ってください。 - double に
==を使うこと、または独立したオラクルを使ったファジングループではなく、運よく成功した1つの例で普遍的な主張を裏付けること。 - 契約に反するフェイクを使うこと(失敗することのない依存関係など)。 テストが実際の動作ではなく、成功パスのスタブを反映してしまいます。フェイクでは失敗を表現できるようにしてください。
- カスタムの
any()引数に対するregisterFallbackValueの登録漏れ。 コンパイラーでは検出できない実行時エラーが発生します。 - 終了しないインジケーターに
pumpAndSettle()を使うこと — 10分のタイムアウトまで停止します。 - 全体またはディレクトリ単位でカバレッジ率にゲートを設けること。 見栄えだけのテストを促し、実際の数値より高く見せます。回復不能なファイルには下限を設け、残りは公開してください。
- 構造上テストできない経路(実際の音声、ネイティブタイル、デバイスの多様性)を対象にしたテストが成功すること — 認められた未対応箇所よりも悪い状態です。手作業での確認を妨げるためです。
- 意味のある
expectがないテスト、テスト間で共有される変更可能な状態、またはtest('works')のような曖昧な名前。
完了の定義
- 各テストは、その内容を検証できる最も軽量な層に配置します。純粋なロジックは
package:testでテストし、UI/プラグインとの境界が論点となる場合に限り、ウィジェット/統合テストを使用します。 - ドメインロジックでは
DateTime.now()ではなくclock.now()を読み取ります。テストではwithClockを使って時刻を固定します。 - すべての変換にラウンドトリップテストと境界値の丸めゴールデンテストを用意します。また、普遍的な主張はすべて、
独立したオラクルを使うシード付きファズテスト/プロパティテストで検証し、
reason:に入力を含めます。 - データ層はモック DAO ではなく
NativeDatabase.memory()を使ってテストし、 ティアダウンでdb.close()を呼び出します。 - 所有するコラボレーターには素の
implementsフェイクを使います。mocktailは外部依存に限って使用し、 カスタムのany()型ごとにregisterFallbackValueを登録します。 - Notifier は
ProviderContainerのオーバーライドを通じてテストし、ティアダウンで破棄します。状態をテストするためにウィジェットをポンプしません。 - 1 つの受け入れゲートで、現実的なシナリオを厳密に検証し、不変条件の維持も確認します。プリミティブには
ランタイムの
assertトリップワイヤーを持たせます。 - カバレッジの下限は、回復不能なバグにつながる ファイル に設定します。カバレッジが実態より高く見える 欠落分を修正し(未テストのファイルも含めます)、生成ファイルを除外します。
- 無期限のアニメーションに
pumpAndSettle()を使いません。タイマーはfakeAsyncで動かします。 - テストスイートは時間予算内に収めます。構造上テストできない経路は、見せかけの成功にせず、リリース前の手動確認で明記します。
関連スキル
- ゴールデンテストのレーン、オーバーフロー/RTL のマトリクス、アクセシビリティテストの正直な限界については、
widget-golden-and-a11y-testingを参照してください。 allocate()プリミティブと、このスキルでテストするClockの注入については、value-objects-money-and-unitsを参照してください。- インメモリテストで検証する DAO/値オブジェクト間の境界については、
persistence-driftを参照してください。 - 100%の下限を設定する from→to の経路を含む、前方のみのマイグレーション手順については、
run-migrationを参照してください。 - 網羅的な switch テストで検証する、sealed な
Result/Failureについては、error-handling-typed-resultsを参照してください。 - silence-review チェックリストで対象とする、破棄された Future の見落としについては、
async-safetyを参照してください。 - フェイクが代わりに実装する、注入可能なポートについては、
service-boundary-and-nativeを参照してください。 - これらのテストレーンと grep をゲートとしてどのように配線するかについては、
ci-pipeline-and-gatesを参照してください。 - 出力をデバイスやリリースをまたいで再現する必要がある場合に、シード付きジェネレーターを固定フィンガープリント表で固定する方法については、
seeded-determinism-and-golden-vectorsを参照してください。
参考資料
- Flutter テストの概要 — https://docs.flutter.dev/testing/overview
- Riverpod のテスト — https://riverpod.dev/docs/essentials/testing
- Drift のテスト(NativeDatabase.memory) — https://drift.simonbinder.eu/docs/testing/
package:clock— https://pub.dev/packages/clockpackage:fake_async— https://pub.dev/packages/fake_asyncpackage:mocktail— https://pub.dev/packages/mocktail