Dart テスト — ユニットテストから統合テストまで

テストのないコードは時限爆弾である — テストは将来の自分のための保険である。

1. 学べること


2. 開発者のリアルな物語

(1) 課題:リファクタリング後のサイレント破損

Charlie が DataPipeline の金額計算ロジックをリファクタリングし、税率を固定値から地域ベースのクエリに変更した。ローカルテストは合格し、デプロイ後、そのリファクタリングが割引スタッキング計算に影響を与えるという予想外の事態が発生した — 割引額がマイナスになり、5,000 件の注文で誤った返金額計算が行われ、2.5 万ドルの直接損失につながった。自動テストがあれば、このバグはデプロイ前にキャッチされた。

(2) 解決策:テストシステム

Dart の test パッケージは包括的なテストフレームワークを提供する。group がテスト構造を整理し、expect + Matchers が豊富なアサーションを提供し、setUp/tearDown がテスト環境を管理する。

DART
group('Order calculation', () {
  test('applies tax correctly', () {
    final order = Order(id: 'T1', amount: 1000.0);
    expect(order.calcTax(0.08), equals(80.0));
  });

  test('discount does not make total negative', () {
    final order = Order(id: 'T2', amount: 100.0, discountRate: 0.5);
    expect(order.total, greaterThan(0));
  });
});
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(3) 効果


3. テストパッケージの基礎

(1) 基本構文

▶ サンプル:基本テスト

DART
// test/order_test.dart
import 'package:test/test.dart';

void main() {
  test('simple addition', () {
    expect(2 + 3, equals(5));
  });

  test('string contains', () {
    expect('DataPipeline v1.0', contains('Pipeline'));
  });

  test('list is not empty', () {
    expect([1, 2, 3], isNotEmpty);
  });
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:group を使ったテストの整理

DART
import 'package:test/test.dart';

double calculateTax(double amount, double rate) => amount * rate;
double calculateTotal(double amount, double taxRate, double discountRate) {
  final discounted = amount * (1 - discountRate);
  return discounted * (1 + taxRate);
}

void main() {
  group('Tax calculation', () {
    test('standard rate', () {
      expect(calculateTax(1000.0, 0.08), equals(80.0));
    });

    test('zero rate', () {
      expect(calculateTax(1000.0, 0.0), equals(0.0));
    });

    test('high rate', () {
      expect(calculateTax(1000.0, 0.25), equals(250.0));
    });
  });

  group('Total calculation', () {
    test('no discount', () {
      expect(calculateTotal(1000.0, 0.08, 0.0), equals(1080.0));
    });

    test('with discount', () {
      expect(calculateTotal(1000.0, 0.08, 0.1), closeTo(972.0, 0.01));
    });

    test('full discount', () {
      expect(calculateTotal(1000.0, 0.08, 1.0), equals(0.0));
    });
  });
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(2) 一般的な Matchers

Matcher 意味
equals(value) 等しい equals(5)
greaterThan(n) より大きい greaterThan(0)
lessThan(n) より小さい lessThan(100)
closeTo(num, delta) おおよそ等しい closeTo(3.14, 0.01)
contains(item) 含む contains('key')
isEmpty 空である isEmpty
isNotEmpty 空でない isNotEmpty
isNull null である isNull
isNotNull null でない isNotNull
isTrue / isFalse ブールチェック isTrue
throwsException 例外をスロー throwsException
isA<Type>() 型チェック isA<String>()

4. setUp / tearDown

▶ サンプル:テスト環境の管理

DART
import 'package:test/test.dart';

class OrderBook {
  final List<double> orders = [];

  void add(double amount) {
    if (amount <= 0) throw ArgumentError('Amount must be positive');
    orders.add(amount);
  }

  double get total => orders.fold(0.0, (a, b) => a + b);
  double get average => orders.isEmpty ? 0 : total / orders.length;
  int get count => orders.length;
}

void main() {
  late OrderBook book;

  setUp(() {
    // 各テスト前に実行
    book = OrderBook();
  });

  tearDown(() {
    // 各テスト後に実行
    // 必要に応じてクリーンアップ
  });

  group('OrderBook', () {
    test('starts empty', () {
      expect(book.count, equals(0));
      expect(book.total, equals(0.0));
    });

    test('add increases count', () {
      book.add(100.0);
      expect(book.count, equals(1));
      book.add(200.0);
      expect(book.count, equals(2));
    });

    test('total sums amounts', () {
      book.add(1500.0);
      book.add(3200.0);
      expect(book.total, equals(4700.0));
    });

    test('rejects negative amount', () {
      expect(() => book.add(-50.0), throwsArgumentError);
    });
  });
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

5. ユニットテスト

(1) 分離テストの原則

▶ サンプル:関数のユニットテスト

DART
import 'package:test/test.dart';

// テスト対象の関数
double parseAmount(String input) {
  final value = double.tryParse(input);
  if (value == null) throw FormatException('Invalid amount: $input');
  if (value <= 0) throw ArgumentError('Amount must be positive');
  return value;
}

String formatUSD(double amount) => '\$${amount.toStringAsFixed(2)} USD';

String classifyAmount(double amount) => switch (amount) {
  >= 10000 => 'Enterprise',
  >= 1000 => 'Premium',
  > 0 => 'Standard',
  _ => throw ArgumentError('Invalid amount'),
};

void main() {
  group('parseAmount', () {
    test('parses valid integer', () {
      expect(parseAmount('1500'), equals(1500.0));
    });

    test('parses valid decimal', () {
      expect(parseAmount('99.99'), closeTo(99.99, 0.001));
    });

    test('throws on invalid input', () {
      expect(() => parseAmount('abc'), throwsFormatException);
    });

    test('throws on negative', () {
      expect(() => parseAmount('-50'), throwsArgumentError);
    });

    test('throws on zero', () {
      expect(() => parseAmount('0'), throwsArgumentError);
    });
  });

  group('formatUSD', () {
    test('formats whole number', () {
      expect(formatUSD(1500), equals('\$1500.00 USD'));
    });

    test('formats decimal', () {
      expect(formatUSD(99.99), equals('\$99.99 USD'));
    });
  });

  group('classifyAmount', () {
    test('Enterprise tier', () {
      expect(classifyAmount(15000), equals('Enterprise'));
    });

    test('Premium tier', () {
      expect(classifyAmount(1500), equals('Premium'));
    });

    test('Standard tier', () {
      expect(classifyAmount(50), equals('Standard'));
    });
  });
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

6. Mock と fake

▶ サンプル:手動 Fake

DART
import 'package:test/test.dart';

// モック対象のインターフェース
abstract class DataSource {
  Future<List<String>> fetchIds();
  Future<double> fetchAmount(String id);
}

// 手動 fake 実装
class FakeDataSource implements DataSource {
  final List<String> _ids;
  final Map<String, double> _amounts;

  FakeDataSource({List<String>? ids, Map<String, double>? amounts})
      : _ids = ids ?? ['ORD-001', 'ORD-002'],
        _amounts = amounts ?? {'ORD-001': 1500.0, 'ORD-002': 3200.0};

  @override
  Future<List<String>> fetchIds() async => _ids;

  @override
  Future<double> fetchAmount(String id) async =>
      _amounts[id] ?? throw Exception('Not found: $id');
}

// テスト対象サービス
class OrderService {
  final DataSource _source;

  OrderService(this._source);

  Future<double> getTotalRevenue() async {
    final ids = await _source.fetchIds();
    double total = 0;
    for (final id in ids) {
      total += await _source.fetchAmount(id);
    }
    return total;
  }
}

void main() {
  group('OrderService', () {
    test('calculates total revenue', () async {
      final fakeSource = FakeDataSource(
        ids: ['ORD-001', 'ORD-002'],
        amounts: {'ORD-001': 1500.0, 'ORD-002': 3200.0},
      );
      final service = OrderService(fakeSource);

      expect(await service.getTotalRevenue(), equals(4700.0));
    });

    test('handles empty data source', () async {
      final fakeSource = FakeDataSource(ids: [], amounts: {});
      final service = OrderService(fakeSource);

      expect(await service.getTotalRevenue(), equals(0.0));
    });
  });
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

7. Bob のシナリオ:DataPipeline テストカバレッジ

▶ サンプル:コアロジックのテスト

DART
import 'package:test/test.dart';

class Order {
  final String id;
  final double amount;
  final String status;
  final String category;
  final double? discountRate;

  Order({
    required this.id,
    required this.amount,
    required this.status,
    required this.category,
    this.discountRate,
  }) {
    if (amount <= 0) throw ArgumentError('Amount must be positive: $amount');
    if (id.isEmpty) throw ArgumentError('ID cannot be empty');
  }

  double get effectiveDiscount => discountRate ?? 0;
  double get discountedAmount => amount * (1 - effectiveDiscount);
  double get tax => discountedAmount * 0.08;
  double get total => discountedAmount + tax;
}

class OrderAnalyzer {
  static double totalRevenue(List<Order> orders) =>
      orders.fold(0.0, (sum, o) => sum + o.total);

  static Map<String, double> revenueByCategory(List<Order> orders) {
    final result = <String, double>{};
    for (final o in orders) {
      result.update(o.category, (v) => v + o.total, ifAbsent: () => o.total);
    }
    return result;
  }

  static List<Order> filterCompleted(List<Order> orders) =>
      orders.where((o) => o.status == 'completed').toList();
}

void main() {
  group('Order', () {
    test('calculates tax correctly', () {
      final order = Order(id: 'T1', amount: 1000.0, status: 'completed', category: 'E');
      expect(order.tax, closeTo(80.0, 0.01));
    });

    test('calculates total with discount', () {
      final order = Order(id: 'T2', amount: 1000.0, status: 'completed', category: 'E', discountRate: 0.1);
      expect(order.total, closeTo(972.0, 0.01));
    });

    test('rejects negative amount', () {
      expect(() => Order(id: 'T3', amount: -100, status: 'completed', category: 'E'),
          throwsArgumentError);
    });

    test('rejects empty ID', () {
      expect(() => Order(id: '', amount: 100, status: 'completed', category: 'E'),
          throwsArgumentError);
    });
  });

  group('OrderAnalyzer', () {
    late List<Order> testOrders;

    setUp(() {
      testOrders = [
        Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'Electronics'),
        Order(id: 'ORD-002', amount: 3200.0, status: 'pending', category: 'Electronics'),
        Order(id: 'ORD-003', amount: 890.0, status: 'completed', category: 'Clothing'),
      ];
    });

    test('filters completed orders', () {
      final completed = OrderAnalyzer.filterCompleted(testOrders);
      expect(completed.length, equals(2));
      expect(completed.every((o) => o.status == 'completed'), isTrue);
    });

    test('calculates total revenue', () {
      final revenue = OrderAnalyzer.totalRevenue(testOrders);
      expect(revenue, greaterThan(0));
    });

    test('groups revenue by category', () {
      final byCategory = OrderAnalyzer.revenueByCategory(testOrders);
      expect(byCategory.containsKey('Electronics'), isTrue);
      expect(byCategory.containsKey('Clothing'), isTrue);
    });

    test('handles empty list', () {
      expect(OrderAnalyzer.totalRevenue([]), equals(0.0));
      expect(OrderAnalyzer.filterCompleted([]), isEmpty);
      expect(OrderAnalyzer.revenueByCategory([]), isEmpty);
    });
  });
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

8. 完全なサンプル:DataPipeline テストスイート

DART
// ============================================
// DataPipeline テストスイート
// group、setup、fake を使った包括的なテスト
// ============================================

import 'package:test/test.dart';

// 本番コード
class PipelineConfig {
  final int batchSize;
  final double taxRate;
  final String outputFormat;

  PipelineConfig({
    this.batchSize = 10000,
    this.taxRate = 0.08,
    this.outputFormat = 'json',
  });

  PipelineConfig copyWith({int? batchSize, double? taxRate, String? outputFormat}) =>
      PipelineConfig(
        batchSize: batchSize ?? this.batchSize,
        taxRate: taxRate ?? this.taxRate,
        outputFormat: outputFormat ?? this.outputFormat,
      );
}

class Order {
  final String id;
  final double amount;
  final String status;
  final String category;

  Order({
    required this.id,
    required this.amount,
    required this.status,
    required this.category,
  }) {
    if (id.isEmpty) throw ArgumentError('Empty order ID');
    if (amount <= 0) throw ArgumentError('Amount must be positive');
  }
}

class ReportGenerator {
  final PipelineConfig config;

  ReportGenerator(this.config);

  Map<String, dynamic> generate(List<Order> orders) {
    final completed = orders.where((o) => o.status == 'completed').toList();
    final revenue = completed.fold<double>(0, (s, o) => s + o.amount);
    final tax = revenue * config.taxRate;

    final byCategory = <String, int>{};
    for (final o in completed) {
      byCategory.update(o.category, (v) => v + 1, ifAbsent: () => 1);
    }

    return {
      'totalOrders': orders.length,
      'completedOrders': completed.length,
      'revenue': revenue,
      'tax': tax,
      'totalWithTax': revenue + tax,
      'byCategory': byCategory,
      'format': config.outputFormat,
    };
  }
}

// テスト
void main() {
  group('PipelineConfig', () {
    test('has default values', () {
      final config = PipelineConfig();
      expect(config.batchSize, equals(10000));
      expect(config.taxRate, equals(0.08));
      expect(config.outputFormat, equals('json'));
    });

    test('copyWith preserves unspecified fields', () {
      final original = PipelineConfig(batchSize: 50000);
      final modified = original.copyWith(taxRate: 0.10);
      expect(modified.batchSize, equals(50000));
      expect(modified.taxRate, equals(0.10));
    });
  });

  group('Order', () {
    test('creates valid order', () {
      final order = Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'E');
      expect(order.id, equals('ORD-001'));
      expect(order.amount, equals(1500.0));
    });

    test('rejects empty ID', () {
      expect(() => Order(id: '', amount: 100, status: 'ok', category: 'E'),
          throwsArgumentError);
    });

    test('rejects non-positive amount', () {
      expect(() => Order(id: 'X', amount: 0, status: 'ok', category: 'E'),
          throwsArgumentError);
      expect(() => Order(id: 'X', amount: -1, status: 'ok', category: 'E'),
          throwsArgumentError);
    });
  });

  group('ReportGenerator', () {
    late PipelineConfig config;
    late ReportGenerator generator;
    late List<Order> testOrders;

    setUp(() {
      config = PipelineConfig(taxRate: 0.08);
      generator = ReportGenerator(config);
      testOrders = [
        Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'Electronics'),
        Order(id: 'ORD-002', amount: 50.0, status: 'completed', category: 'Books'),
        Order(id: 'ORD-003', amount: 3200.0, status: 'pending', category: 'Electronics'),
      ];
    });

    test('counts total and completed orders', () {
      final report = generator.generate(testOrders);
      expect(report['totalOrders'], equals(3));
      expect(report['completedOrders'], equals(2));
    });

    test('calculates revenue from completed only', () {
      final report = generator.generate(testOrders);
      expect(report['revenue'], equals(1550.0));
    });

    test('applies tax rate from config', () {
      final report = generator.generate(testOrders);
      expect(report['tax'], closeTo(124.0, 0.01));
    });

    test('groups by category', () {
      final report = generator.generate(testOrders);
      final byCategory = report['byCategory'] as Map<String, int>;
      expect(byCategory['Electronics'], equals(1));
      expect(byCategory['Books'], equals(1));
    });

    test('handles empty order list', () {
      final report = generator.generate([]);
      expect(report['totalOrders'], equals(0));
      expect(report['revenue'], equals(0.0));
    });

    test('respects output format config', () {
      final jsonGen = ReportGenerator(PipelineConfig(outputFormat: 'json'));
      final csvGen = ReportGenerator(PipelineConfig(outputFormat: 'csv'));
      expect(jsonGen.generate(testOrders)['format'], equals('json'));
      expect(csvGen.generate(testOrders)['format'], equals('csv'));
    });
  });
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

❓ よくある質問

Q: テストファイルはどこに配置すべきですか? A: プロジェクトルートの test/ フォルダ内に、_test.dart で終わるファイル名で配置します。dart test を実行すると、すべてのテストが自動的に検出されます。

Q: ユニットテストと統合テストの違いは何ですか? A: ユニットテストは外部リソースに依存せず、単一の関数/クラスを分離してテストします。統合テストは複数モジュールの連携を検証し、ファイル、データベース、ネットワークを含む場合があります。

Q: setUp はグループ内にネストできますか? A: はい。外側グループの setUp が最初に実行され、その後内側のものが実行されます。基本設定の共有 + グループごとのカスタム設定に適しています。

Q: mockito と手動 fake のどちらを選ぶべきですか? A: シンプルなインターフェースには手動 fake(コードが少なく、型安全)を使います。複雑なインターフェースや呼び出し回数の検証が必要な場合は mockito を使います。Dart は手動 fake の優先を推奨しています。

Q: 非同期コードをテストするには? A: テストコールバックは async をサポートしているので、test('...', () async { ... }) を直接使ってください。expect も Future をサポートします。await を忘れないでください。

Q: テストカバレッジを確認するには? A: dart test --coverage=coverage を実行し、coverage パッケージの format_coverage ツールを使ってレポートを生成してください。

Q: 例外をスローするコードをテストするには? A: expect(() => someFunction(), throwsException) または throwsFormatExceptionthrowsArgumentError などのより具体的なバリアントを使用してください。


📖 まとめ


📝 練習問題

  1. 基礎(難易度 ⭐)formatUSD(double amount) 関数に対して 5 つのユニットテストを書いてください:通常の金額、ゼロ、負、非常に大きな数、小数精度。
  2. 中級(難易度 ⭐⭐)DataSource インターフェースを実装する FakeApiService を作成してください。それを使って OrderServicegetTotalRevenue()getOrderById() メソッドをテストします。成功と失敗のシナリオの両方を含めてください。
  3. 上級(難易度 ⭐⭐⭐):DataPipeline の ReportGenerator 用の完全なテストスイートを書いてください:setUp を使ってテストデータを共有、group で機能別テスト、エッジケース(空リスト、単一アイテム、すべて pending ステータス)テスト、カバレッジ目標 ≥ 90%。

← 前のレッスン | 次のレッスン →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%