
ここまでで、Flutterを使った業務アプリの基本構造はほぼ完成しました。
画面構成、状態管理、API通信まで一通り揃っています。
この段階で次に考えるべきなのが、「どうやって壊れにくく保つか」です。
つまり
テストと品質担保 の話になります。
FlutterはUIフレームワークでありながら、テストを書きやすい設計が最初から用意されています。
ただし、やみくもに書くとコストだけが増えるのも事実です。
このSTEPでは、「どこまで書くか」「何を捨てるか」という判断軸を整理します。
Unit / Widgetテスト
Flutterのテストは、大きく分けると Unitテスト と Widgetテスト があります。
Unitテストは、純粋なロジックを対象にします。
ViewModelや計算ロジックなど、「UIと無関係な部分」が主な対象です。
ここはバックエンドのテストとほぼ同じ感覚で書けます。
Widgetテストは、UIを含めた振る舞いを確認します。
ボタンを押したらテキストが表示される、ローディングが出る、といった「画面の振る舞い」を担保する役割です。
E2Eテストも存在しますが、Flutterではコストが高くなりがちなので、
Unit + Widgetで8割を守る、という割り切りが実務ではちょうど良い落とし所になります。
どこまでテストを書くかの判断基準
実務で一番大事なのは、「全部テストしようとしない」ことです。
テストは品質を上げるための手段であって、目的ではありません。
判断基準としておすすめなのは、
「壊れたら困るロジックかどうか」
という一点です。
例えば、
・APIレスポンスを画面用データに変換する処理
・状態遷移の条件分岐
・業務ルールを含む計算
こういった部分は、UIが変わっても残り続けます。
逆に、レイアウトや文言の細かい違いは、テストコストに見合わないケースが多いです。
Flutterでは、ViewModelを薄く・純粋に保つほど、テスト対象が自然に見えてくるのが特徴です。
CIでの実行イメージ
Flutterのテストは、ローカルでもCIでも同じコマンドで実行できます。
flutter test
これだけで、
・Unitテスト
・Widgetテスト
がまとめて実行されます。
GitHub ActionsなどのCIに組み込む場合も、
「Flutter SDKをセットアップして flutter test を叩く」
というシンプルな構成で済みます。
重要なのは、テストがあることで安心してリファクタできる状態を作ることです。
テストは、未来の自分やチームへの保険だと考えると、書く意味が見えてきます。
成果物:最低限の自動テスト
ここでは、STEP7で作った UserViewModel を対象に、
「最低限これだけあれば安心」というUnitテストを書きます。
事前に以下のコードを実行
// step9のプロジェクトを作成
flutter create step9
// プロジェクトフォルダに入る
cd step9
// providerの環境を追加
flutter pub add provider
lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/user_list_screen.dart';
import 'view_models/user_view_model.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => UserViewModel(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: UserListScreen(),
);
}
}
lib/models/user.dart
class User {
final int id;
final String name;
User({
required this.id,
required this.name,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
);
}
}
lib/view_models/user_view_model.dart
import 'package:flutter/material.dart';
import '../models/user.dart';
class UserViewModel extends ChangeNotifier {
bool isLoading = false;
List<User> users = [];
String? error;
Future<void> fetchUsers() async {
isLoading = true;
notifyListeners();
await Future.delayed(const Duration(milliseconds: 300));
users = [
User(id: 1, name: 'Alice'),
User(id: 2, name: 'Bob'),
];
isLoading = false;
notifyListeners();
}
}
lib/screens/user_list_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../view_models/user_view_model.dart';
class UserListScreen extends StatelessWidget {
const UserListScreen({super.key});
@override
Widget build(BuildContext context) {
final vm = context.watch<UserViewModel>();
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: vm.isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
children: vm.users
.map((u) => ListTile(title: Text(u.name)))
.toList(),
),
floatingActionButton: FloatingActionButton(
onPressed: vm.fetchUsers,
child: const Icon(Icons.download),
),
);
}
}
Unitテスト(ViewModel)
test/user_view_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:step9/view_models/user_view_model.dart';
void main() {
test('fetchUsersを呼ぶとユーザーが2件入る', () async {
final vm = UserViewModel();
expect(vm.users, isEmpty);
expect(vm.isLoading, isFalse);
await vm.fetchUsers();
expect(vm.isLoading, isFalse);
expect(vm.users.length, 2);
expect(vm.users.first.name, 'Alice');
});
}
Widgetテスト(画面)
test/user_list_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:step9/screens/user_list_screen.dart';
import 'package:step9/view_models/user_view_model.dart';
void main() {
testWidgets('ローディング中はインジケータが表示される',
(WidgetTester tester) async {
final vm = UserViewModel()..isLoading = true;
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: vm,
child: const MaterialApp(
home: UserListScreen(),
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('ユーザー名が表示される',
(WidgetTester tester) async {
final vm = UserViewModel()
..users = [
User(id: 1, name: 'Test User'),
];
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: vm,
child: const MaterialApp(
home: UserListScreen(),
),
),
);
expect(find.text('Test User'), findsOneWidget);
});
}
実行
flutter test
Resolving dependencies...
Downloading packages...
test_api 0.7.8 (0.7.9 available)
Got dependencies!
1 package has newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
00:00 +0: /Users/yugeta/web/test/flutter/step9/test/user_view_model_test.dart: fetchUsersを呼ぶとユーザーが2件入る
00:01 +0: /Users/yugeta/web/test/flutter/step9/test/user_view_model_test.dart: fetchUsersを呼ぶとユーザーが2件入る
00:01 +1: /Users/yugeta/web/test/flutter/step9/test/user_view_model_test.dart: fetchUsersを呼ぶとユーザーが2件入る
00:01 +2: /Users/yugeta/web/test/flutter/step9/test/user_list_screen_test.dart: ユーザー名が表示される
00:01 +3: /Users/yugeta/web/test/flutter/step9/test/user_list_screen_test.dart: ユーザー名が表示される
00:01 +3: All tests passed!
こんな感じで、終了します。
このSTEPのまとめ
このSTEPで重要なのは、
・全部テストしない
・壊れたら困るところだけ守る
・CIで自動実行できる状態を作る
というバランス感覚です。
ここまで来ると、Flutterは「UIを作るための技術」ではなく、
「長く運用できるサービスを作るための基盤」として使える状態になります。
なんとなく、テストまで用意されている Flutterって、入り口から出口まで網羅されているフレームワークという事がわかってきましたよね。
0 件のコメント:
コメントを投稿