Dart テスト — ユニットテストから統合テストまで
テストのないコードは時限爆弾である — テストは将来の自分のための保険である。
1. 学べること
- テストパッケージ:Matchers を使った test() / group() / expect()
- ユニットテスト:関数とクラスの分離テスト
- 統合テスト:複数モジュールの連携検証
- Mock と fake:mockito / fake_async
- Bob のシナリオ:DataPipeline コアロジックのテストカバレッジ
2. 開発者のリアルな物語
(1) 課題:リファクタリング後のサイレント破損
Charlie が DataPipeline の金額計算ロジックをリファクタリングし、税率を固定値から地域ベースのクエリに変更した。ローカルテストは合格し、デプロイ後、そのリファクタリングが割引スタッキング計算に影響を与えるという予想外の事態が発生した — 割引額がマイナスになり、5,000 件の注文で誤った返金額計算が行われ、2.5 万ドルの直接損失につながった。自動テストがあれば、このバグはデプロイ前にキャッチされた。
(2) 解決策:テストシステム
Dart の test パッケージは包括的なテストフレームワークを提供する。group がテスト構造を整理し、expect + Matchers が豊富なアサーションを提供し、setUp/tearDown がテスト環境を管理する。
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));
});
});
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
(3) 効果
- リファクタリング後にテストを自動実行し、5 分間で 200 以上のテストケースをカバー
- エッジケース(負の金額、空リスト、null 値)が見落とされなくなる
- テストカバレッジが 0% から 85% に増加し、リグレッションバグが 90% 削減
3. テストパッケージの基礎
(1) 基本構文
▶ サンプル:基本テスト
// 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);
});
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:group を使ったテストの整理
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));
});
});
}
> 出力: ローカルの 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
▶ サンプル:テスト環境の管理
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);
});
});
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
5. ユニットテスト
(1) 分離テストの原則
▶ サンプル:関数のユニットテスト
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'));
});
});
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
6. Mock と fake
▶ サンプル:手動 Fake
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));
});
});
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
7. Bob のシナリオ:DataPipeline テストカバレッジ
▶ サンプル:コアロジックのテスト
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);
});
});
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
8. 完全なサンプル:DataPipeline テストスイート
// ============================================
// 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'));
});
});
}
> 出力: ローカルの 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)またはthrowsFormatException、throwsArgumentErrorなどのより具体的なバリアントを使用してください。
📖 まとめ
- test パッケージは 3 つのコア API を提供:test/group/expect、豊富な Matchers と組み合わせる
groupがテスト構造を整理し、setUp/tearDownがテスト環境を管理する- ユニットテストは外部リソースに依存せず、単一の関数/クラスを分離してテストする
- Fake 実装は実際の依存関係を置き換え、テストを高速で再現可能にする
- DataPipeline テストカバレッジ:Order 検証、ReportGenerator 計算、エッジケース
📝 練習問題
- 基礎(難易度 ⭐):
formatUSD(double amount)関数に対して 5 つのユニットテストを書いてください:通常の金額、ゼロ、負、非常に大きな数、小数精度。 - 中級(難易度 ⭐⭐):
DataSourceインターフェースを実装するFakeApiServiceを作成してください。それを使ってOrderServiceのgetTotalRevenue()とgetOrderById()メソッドをテストします。成功と失敗のシナリオの両方を含めてください。 - 上級(難易度 ⭐⭐⭐):DataPipeline の
ReportGenerator用の完全なテストスイートを書いてください:setUpを使ってテストデータを共有、groupで機能別テスト、エッジケース(空リスト、単一アイテム、すべて pending ステータス)テスト、カバレッジ目標 ≥ 90%。