Share
Embed
Flutterで単体テストを行う方法とGitHub Actionsを使った自動化
Flutterでの単体テスト実施方法と、テスタブルなコードにリファクタリングするためのテクニック.
またそれをGitHub Actionsで自動化してカバレッジを可視化する方法
tokku5552
May 11, 2022
Transcript
42 slides
-
はじめに このスライドの内容は以下の記事をかみ砕いて説明するものになります。 ・Flutterで単体テストを書く : https://qiita.com/tokkun5552/items/ede8460bef4892f48e37 ・【Flutter】Providerで最低限のDIを行ってテスタブルなコードにリファクタリングする : https://qiita.com/tokkun5552/items/7af34769104e94f50745 ・【Flutter】GitHubActionsでテストと静的解析を自動化する https://qiita.com/tokkun5552/items/2eb6793501c152dabf33
・サンプルコードの GitHubリポジトリ tokku5552/TODOAppSample-Flutter: A sample Todo App with Provider -
Flutterにおけるテストの種類 Flutterには3種類のテストがある 公式ページ:https://flutter.dev/docs/cookbook/testing ・Unit Test ・Widget Test ・Integration Test いわゆる単体テスト。関数、メソッド、クラスの検証を行う
Widgetが正しく生成されるかのテスト。 結合テスト。シナリオを書いてエミュレータ上で自動操作によるテス トが行える。 -
Flutterにおけるテストの種類 Flutterには3種類のテストがある 公式ページ:https://flutter.dev/docs/cookbook/testing ・Unit Test ・Widget Test ・Integration Test いわゆる単体テスト。関数、メソッド、クラスの検証を行う
Widgetが正しく生成されるかのテスト。 結合テスト。シナリオを書いてエミュレータ上で自動操作によるテス トが行える。 今回は主に、実装コストと効果の バランスが一番よさそうな Unit Testを扱う -
Unitテストの書き方と実行方法 プロジェクトルートの testフォルダの下に XXX_test.dartファイルを作成 import 'package:flutter_test/flutter_test.dart' ; import 'package:todo_app_sample_flutter/data/todo_item.dart' ;
void main() { group('TodoItemのゲッターのテスト ', () { final TodoItem todoItem = TodoItem( id: 0, title: 'title', body: 'body', createdAt: DateTime (2020, 1, 1), updatedAt: DateTime (2020, 1, 1), isDone: true, ); test('idのテスト', () { expect (todoItem.getId, 0); }); -
Unitテストの書き方と実行方法 プロジェクトルートの testフォルダの下に XXX_test.dartファイルを作成 import 'package:flutter_test/flutter_test.dart' ; import 'package:todo_app_sample_flutter/data/todo_item.dart' ;
void main() { group('TodoItemのゲッターのテスト ', () { final TodoItem todoItem = TodoItem( id: 0, title: 'title', body: 'body', createdAt: DateTime (2020, 1, 1), updatedAt: DateTime (2020, 1, 1), isDone: true, ); test('idのテスト', () { expect (todoItem.getId, 0); }); main関数の中に 実際のテストを記載 test(‘テストケース名’,(){ 実際のテスト処理 expect(結果,期待する値); }); group()でテストケースを まとめることが出来る。 -
ここからが本題 class MemoDetailModel extends ChangeNotifier { final FirebaseAuth auth =
FirebaseAuth.instance; final FirebaseFirestore firestore = FirebaseFirestore.instance; Future addMemo() async { final memo = Memo( ~~ ); ~~何かデータ追加前にチェックしたりとか~~ final collection = firestore.collection('users'); final user = auth.currentUser; if (user != null) { collection.doc(user.uid).collection('memos').add({ 'title': memo.title, 'updatedAt': memo.updatedAt, 'happenedAt': memo.happenedAt, }); } notifyListeners(); } } よくありそうなChangeNotifierを 継承したドメインモデル ビジネスロジックを実装しているので 単体テストを行いたいが、 右のような状態ではテスト出来ない。 どこが問題? -
なぜテスト出来ない? ・FirebaseAuthやFirestoreの インスタンスを生成している ※main()内でイニシャライズが必要 ・addMemo()ではバリデーションや、 データ追加前の正当性チェックなどを 行っているが、同時に Firebaseの 通信処理も行ってしまっている class
MemoDetailModel extends ChangeNotifier { final FirebaseAuth auth = FirebaseAuth.instance; final FirebaseFirestore firestore = FirebaseFirestore.instance; Future addMemo() async { final memo = Memo( ~~ ); ~~何かデータ追加前にチェックしたりとか~~ final collection = firestore.collection('users'); final user = auth.currentUser; if (user != null) { collection.doc(user.uid).collection('memos').add({ 'title': memo.title, 'updatedAt': memo.updatedAt, 'happenedAt': memo.happenedAt, }); } notifyListeners(); } } -
ビジネスロジックの集約と依存関係の分離 ・例えばUIのクラスにすべてのロ ジックを詰め込んで実装すること もできるが、それをやってしまうと 一つのクラスが肥大化してしまう ・肥大化したクラスはメンテナンス しづらく、読みにくく、テストしにく い Robert
C. Martin著 Clean Architecture 達人に学ぶソフトウェアの構造と設計 -
ビジネスロジックの集約と依存関係の分離 ・例えばUIのクラスにすべてのロ ジックを詰め込んで実装すること もできるが、それをやってしまうと 一つのクラスが肥大化してしまう ・肥大化したクラスはメンテナンス しづらく、読みにくく、テストしにく い Robert
C. Martin著 Clean Architecture 達人に学ぶソフトウェアの構造と設計 テスタブルなコードにするために 考えを少しだけ取り入れることにする -
インターフェース ・メソッドの実装を強制する仕組み ・メソッドだけ定義して実際の処理は書かない →内部でDB、Firebase等の処理を書くこともない。 (依存しない) ・インターフェースを実装したクラスは、 インターフェースに定義されているメソッドを 実装しなければならない →メソッドだけを外部から見ると、全く同じ動きをする interface
Hoge{ void doHoge(); } class HogeImpl implements Hoge{ @override public void doHoge() { // 実際の処理 } } インターフェース(Javaでの例) インターフェースを実装した例 -
インターフェース ・メソッドの実装を強制する仕組み ・メソッドだけ定義して実際の処理は書かない →内部でDB、Firebase等の処理を書くこともない。 (依存しない) ・インターフェースを実装したクラスは、 インターフェースに定義されているメソッドを 実装しなければならない →メソッドだけを外部から見ると、全く同じ動きをする interface
Hoge{ void doHoge(); } class HogeImpl implements Hoge{ @override public void doHoge() { // 実際の処理 } } インターフェース(Javaでの例) インターフェースを実装した例 ただしDartにはインターフェースが ないので、今回はabstract というのを使う ※implicit interfaceという便利な機能があるが 話がややこしくなるので割愛 -
インターフェースの使い方 ・コンストラクタで インターフェースを受け取る ・インターフェースにはメソッドの 決まりが書いてあるので、 記載のあるメソッドは そのまま使うことが出来る class TodoItemDetailModel extends
ChangeNotifier { TodoItemDetailModel ({ @required TodoItemRepository todoItemRepository , }) : _todoItemRepository = todoItemRepository ; final TodoItemRepository _todoItemRepository ; Future <void> add() async { if (todoTitle == null || todoTitle .isEmpty) { final Error error = ArgumentError ('タイトルを入力してください。 '); throw error; } await _todoItemRepository .create( ~~ ); notifyListeners (); } https://github.com/tokku5552/TODOAppSample-Flutter/blob/v1.3/lib/presentation/todo_item_detail/todo_item_detail_model.dart -
インターフェースの使い方 ・コンストラクタで インターフェースを受け取る ・インターフェースにはメソッドの 決まりが書いてあるので、 記載のあるメソッドは そのまま使うことが出来る class TodoItemDetailModel extends
ChangeNotifier { TodoItemDetailModel ({ @required TodoItemRepository todoItemRepository , }) : _todoItemRepository = todoItemRepository ; final TodoItemRepository _todoItemRepository ; Future <void> add() async { if (todoTitle == null || todoTitle .isEmpty) { final Error error = ArgumentError ('タイトルを入力してください。 '); throw error; } await _todoItemRepository .create( ~~ ); notifyListeners (); } https://github.com/tokku5552/TODOAppSample-Flutter/blob/v1.3/lib/presentation/todo_item_detail/todo_item_detail_model.dart 外部通信など(DBやFirebaseを使う処理)はリ ポジトリーというクラスに集約して、 ビジネスロジックから切り離す ビジネスロジックはインターフェースに 依存する。(具体的な実装に依存しない。) -
ProviderでDIする ・例えばmainの直下などで、 右図のように定義する ・providersの中はProviderでも、 StreamProviderでも、 ChangeNotifierProviderでも 定義できる。 ・create:でImplの方を渡す void main()
{ runApp( MultiProvider ( providers: [ Provider <TodoItemRepository >( create: (_) => TodoItemRepositoryImpl (), ) ], child: App (), ), ); } -
ProviderでDIする ・modelを生成する場所(例えば page内のChangeNotifierProvider) でコンストラクタに渡す。 ・先ほどmainで定義した Providerは、定義した場所より 下の階層のcontextから context.read<型>() で呼び出せる ChangeNotifierProvider
<TodoItemDetailModel >( create: (_) => TodoItemDetailModel ( todoItemRepository: context .read<TodoItemRepository >(), ), Providerの詳細 参考:https://qiita.com/kabochapo/items/a90d8438243c27e2f6d9 -
テスト用Repositoryの実装の例 ・DBのインスタンスは生成しない ・実際のデータは_data ・アプリ側と同様に TodoItemRepositoryを実装する class TodoItemRepositoryMemImpl implements TodoItemRepository {
final _data = <int, TodoItem>{}; ~~ @override Future<TodoItem> find({@required int id}) { return Future.value(_data[id]); } ~~ -
テスト側で使う例 ・テスト用のrepositoryを 直接インスタンス化する ・それを直接modelに渡す ・テストの中でrepositoryを 直接操作することが可能 void main() { final
repository = TodoItemRepositoryMemImpl (); final model = TodoItemDetailModel (todoItemRepository: repository ); final dummyDate = DateTime.now(); -
GitHub Actionsでテスト自動化 ・プロジェクト直下に.github/workflowsというフォルダを作成し、その中にyamlファイル を置いていきます。 テスト用yamlファイル↓ yamlの詳しい解説↓ flutter test の実行を行ってる https://qiita.com/tokkun5552/items/2eb6793501c152dabf33#%E3%83 %86%E3%82%B9%E3%83%88%E8%87%AA%E5%8B%95%E5%8C%96
https://qiita.com/sensuikan1973/items/0c5efb93e5db54f9d8a2