こんにちは、Pentagonでアプリ開発している髙谷です。

Flutterでモバイルアプリを開発していると、複雑な条件分岐などのロジックを実装する場面が多くあります。その際に重要なのが「正しく処理されているか?」をテストで担保することです。

本記事では、配送オプションを判定するケースを題材に、条件分岐・例外処理・非同期考慮まで含めたテストの実装方針をご紹介します。

【こんな人に読んで欲しい】

  • Flutterアプリでロジックの正しさをテストで保証したい
  • 条件分岐や例外処理を含むロジックをどうテストすればよいか迷っている
  • 実務的なユースケースを通じて、実装・テスト両方の設計を学びたい

【この記事を読むメリット】

  • 条件分岐の多い処理をどう整理・テストするかがわかる
  • Dartの標準テストフレームワークを使った便利なアサーションや例外検証方法が学べる
  • テストの構造化、例外ハンドリングの考え方、ユースケースを通じた実践理解が得られる

【結論】

Flutterでのロジックテストは、ロジック・例外・非同期処理を分けて考えることと、アサーションメソッドや構造化の工夫が鍵です。 小さなロジック単位でテストを書き、仕様と実装のズレを未然に防ぐ開発体制を構築しましょう。

この記事の前提

  • Dartの flutter_test パッケージを使用したユニットテスト
  • Flutterアプリの中にあるビジネスロジックのテストに焦点
  • 非同期処理やエラー検証、アサーション方法も含めて解説
  • 環境
    • Flutter 3.32.5

ユースケース:配送オプションの即日可否を判定

本記事では、以下のような「配送オプション判定ロジック」を題材にします。

条件(例)即日配送
プレミアム会員可能
通常会員 × 午前に注文可能
通常会員 × 午後に注文不可
離島で注文不可
土日に注文不可
現在から1年超の日時に注文不可(例外発生)

テストコード実装

import 'package:flutter_test/flutter_test.dart';

/// 会員種別
enum MembershipType {
  /// 通常会員
  regular,

  /// プレミアム会員
  premium,
  ;
}

/// 地域種別
enum LocationType {
  /// 本島
  mainland,

  /// 離島
  remoteIsland,
  ;
}

/// 配送判定に必要な文脈
class DeliveryContext {
  DeliveryContext({
    required this.membership,
    required this.location,
    required this.orderTime,
  });

  final MembershipType membership;
  final LocationType location;
  final DateTime orderTime;

  /// 午前中の注文かどうか
  bool get isMorningOrder {
    return orderTime.hour < 12;
  }

  /// 現在から1年を超えて未来の注文かどうか
  bool get inOneYearLaterOrder {
    final now = DateTime.now();
    final oneYearLater = now.add(const Duration(days: 365));
    return orderTime.isAfter(oneYearLater);
  }

  /// 週末の注文かどうか
  bool get isWeekendOrder {
    return orderTime.weekday == DateTime.saturday ||
        orderTime.weekday == DateTime.sunday;
  }

  /// プレミアム会員かどうか
  bool get isPremiumMember {
    return membership == MembershipType.premium;
  }

  /// 離島かどうか
  bool get isRemoteIsland {
    return location == LocationType.remoteIsland;
  }
}

/// 配送判定ロジック
class DeliveryOption {
  DeliveryOption(this.context);

  final DeliveryContext context;

  /// 即日配送が可能かどうか
  bool get isSameDayAvailable {
    if (context.isRemoteIsland) {
      return false;
    }
    if (context.isWeekendOrder) {
      return false;
    }
    if (context.isPremiumMember) {
      return true;
    }
    return context.isMorningOrder;
  }
}

/// 配送判定サービス
class DeliveryService {
  /// 即日配送が可能かどうかをチェック
  Future<bool> checkSameDayAvailability(DeliveryContext context) async {
    await Future<void>.delayed(const Duration(seconds: 3));
    if (context.inOneYearLaterOrder) {
      throw const FormatException('注文日時が現在から1年以上未来に設定されています');
    }
    return DeliveryOption(context).isSameDayAvailable;
  }
}

void main() {
  group('即日配送判定ロジック', () {
    test('プレミアム会員 → 常に即日配送可(本島、平日の場合)', () {
      final context = DeliveryContext(
        membership: MembershipType.premium,
        location: LocationType.mainland,
        orderTime: DateTime(2025, 7, 29, 18),
      );
      final option = DeliveryOption(context);
      expect(option.isSameDayAvailable, true);
    });

    test('通常会員 → 午前の注文は即日配送可(本島、平日の場合)', () {
      final context = DeliveryContext(
        membership: MembershipType.regular,
        location: LocationType.mainland,
        orderTime: DateTime(2025, 7, 29, 10),
      );
      final option = DeliveryOption(context);
      expect(option.isSameDayAvailable, true);
    });

    test('通常会員 → 午前12時ちょうどの注文は即日配送不可', () {
      final context = DeliveryContext(
        membership: MembershipType.regular,
        location: LocationType.mainland,
        orderTime: DateTime(2025, 7, 29, 12), // 午前12時
      );
      final option = DeliveryOption(context);
      expect(option.isSameDayAvailable, false);
    });

    test('通常会員 → 午後の注文は即日配送不可', () {
      final context = DeliveryContext(
        membership: MembershipType.regular,
        location: LocationType.mainland,
        orderTime: DateTime(2025, 7, 29, 14),
      );
      final option = DeliveryOption(context);
      expect(option.isSameDayAvailable, false);
    });

    test('離島 → 常に即日配送不可', () {
      final context = DeliveryContext(
        membership: MembershipType.premium,
        location: LocationType.remoteIsland,
        orderTime: DateTime(2025, 7, 29, 10),
      );
      final option = DeliveryOption(context);
      expect(option.isSameDayAvailable, false);
    });

    test('週末(土曜) → 常に即日配送不可', () {
      final context = DeliveryContext(
        membership: MembershipType.premium,
        location: LocationType.mainland,
        orderTime: DateTime(2025, 8, 2, 10), // 土曜日
      );
      final option = DeliveryOption(context);
      expect(option.isSameDayAvailable, false);
    });

    group('バリデーションと例外処理のテスト', () {
      test('未来すぎる注文日時 → 例外FormatException発生', () {
        final context = DeliveryContext(
 membership: MembershipType.premium,
 location: LocationType.mainland,
 orderTime: DateTime.now().add(const Duration(days: 400)),
        );
        final service = DeliveryService();
        expect(
 () => service.checkSameDayAvailability(context),
 throwsA(
isA<FormatException>().having(
(e) => e.message,
'message',
contains('未来に設定されています'),
),
 ),
        );
      });

      test('正常な注文日時 → 例外なし', () async {
        final context = DeliveryContext(
 membership: MembershipType.premium,
 location: LocationType.mainland,
 orderTime: DateTime.now(),
        );
        final service = DeliveryService();
        final result = await service.checkSameDayAvailability(context);
        expect(result, true);
      });

      test('1年後ちょうどの注文日時 → 例外なし', () async {
        final context = DeliveryContext(
 membership: MembershipType.premium,
 location: LocationType.mainland,
 orderTime: DateTime.now().add(const Duration(days: 365)), // 1年後
        );
        final service = DeliveryService();
        final result = await service.checkSameDayAvailability(context);
        expect(result, true);
      });
    });
  });
}

flutter testコマンドを実行すると以下のようなログが出力されます。

00:05 +0: 即日配送判定ロジック プレミアム会員 → 常に即日配送可(本島、平日の場合)
00:05 +1: 即日配送判定ロジック プレミアム会員 → 常に即日配送可(本島、平日の場合)
00:05 +1: 即日配送判定ロジック 通常会員 → 午前の注文は即日配送可(本島、平日の場合)
00:05 +2: 即日配送判定ロジック 通常会員 → 午前の注文は即日配送可(本島、平日の場合)
00:05 +2: 即日配送判定ロジック 通常会員 → 午前12時ちょうどの注文は即日配送不可    
00:05 +3: 即日配送判定ロジック 通常会員 → 午前12時ちょうどの注文は即日配送不可    
00:05 +3: 即日配送判定ロジック 通常会員 → 午後の注文は即日配送不可
00:05 +4: 即日配送判定ロジック 通常会員 → 午後の注文は即日配送不可
00:05 +4: 即日配送判定ロジック 離島 → 常に即日配送不可
00:05 +5: 即日配送判定ロジック 離島 → 常に即日配送不可
00:05 +5: 即日配送判定ロジック 週末(土曜) → 常に即日配送不可
00:05 +6: 即日配送判定ロジック 週末(土曜) → 常に即日配送不可
00:05 +6: 即日配送判定ロジック バリデーションと例外処理のテスト 現在から1年超の注文日時 → 例外FormatException発生
00:06 +6: 即日配送判定ロジック バリデーションと例外処理のテスト 現在から1年超の注文日時 → 例外FormatException発生
00:07 +6: 即日配送判定ロジック バリデーションと例外処理のテスト 現在から1年超の注文日時 → 例外FormatException発生
00:08 +6: 即日配送判定ロジック バリデーションと例外処理のテスト 現在から1年超の注文日時 → 例外FormatException発生
00:08 +7: 即日配送判定ロジック バリデーションと例外処理のテスト 現在から1年超の注文日時 → 例外FormatException発生
00:08 +7: 即日配送判定ロジック バリデーションと例外処理のテスト 正常な注文日時 → 例外なし
00:09 +7: 即日配送判定ロジック バリデーションと例外処理のテスト 正常な注文日時 → 例外なし
00:10 +7: 即日配送判定ロジック バリデーションと例外処理のテスト 正常な注文日時 → 例外なし
00:11 +7: 即日配送判定ロジック バリデーションと例外処理のテスト 正常な注文日時 → 例外なし
00:11 +8: 即日配送判定ロジック バリデーションと例外処理のテスト 正常な注文日時 → 例外なし
00:11 +8: 即日配送判定ロジック バリデーションと例外処理のテスト 1年後ちょうどの注文日時 → 例外なし
00:12 +8: 即日配送判定ロジック バリデーションと例外処理のテスト 1年後ちょうどの注文日時 → 例外なし
00:13 +8: 即日配送判定ロジック バリデーションと例外処理のテスト 1年後ちょうどの注文日時 → 例外なし
00:14 +8: 即日配送判定ロジック バリデーションと例外処理のテスト 1年後ちょうどの注文日時 → 例外なし
00:14 +9: 即日配送判定ロジック バリデーションと例外処理のテスト 1年後ちょうどの注文日時 → 例外なし
00:14 +9: All tests passed!

テストコードTips

Tipsメリット
group() でセクションを分けるテストケースの分類とログの見やすさ向上。特に条件分岐が多い場合に有効。
expect(…, equals(…))型チェックを含め、より明確な比較が可能。ListやMapの比較に強い。
throwsA() + having()例外の種類だけでなくメッセージも検証でき、ユーザー向けエラーメッセージが正しいかの確認に有効。
非同期関数は await + expect を使うFuture などの戻り値を直接確認できる。
バリデーションや例外もテストに含める境界値・異常値・未設定値もテストすることでロジックの堅牢性が向上。
モックデータの生成を共通化しないテストケース内で条件を明示的に構築する方が読みやすい。
時間を扱うテストは注意DateTime.now() などは実行タイミングに依存するため、必要に応じて固定値にするかclockパッケージなどの導入を検討する。

まとめ

本記事では、Flutterでのビジネスロジックに対して、仕様の整理 → クラス設計 → テスト実装 → 例外処理の確認までを一貫して行う方法を紹介しました。

ロジックの仕様が複雑になるほど、テストコードの存在は重要になります。テストコードがあることで安心して仕様変更やリファクタリングができる環境が整います。