Flutterで単体テストを行う方法とGitHub
Actionsを使った自動化
KBOYのFlutter大学 勉強会 2021/02/24
はじめに
このスライドの内容は以下の記事をかみ砕いて説明するものになります。
・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 
なぜテストするのか
結論:ソフトウェアの品質を高めるため
ソフトウェアの品質が下がる要因
・人間が書いているので間違いや考慮漏れは起こる→仕方がないこと
・ある場所に変更を加えることで、別の場所に影響を及ぼしバグが生まれる
テストが品質を上げる理由
・考慮漏れや間違いが起きにくくなる(客観的に見ることが出来る)
・変更時にテストして、動きが変わっていないことを確かめる
品質向上とリリースのスピードはトレードオフ
「Done is better than perfect」 → 完璧を目指すよりもまず終わらせろ
とはいえ全く動かさずにリリースするわけにはいかない
品質の落としどころ
・ベータ版としてリリース → ユーザにテストしてもらう
・モンキーテスト → テストケースを決めずに適当にクリックしたり入力したりしてみる
選択肢として
ビジネスロジックの自動テストを考えてみる
Flutterでのテストことはじめ
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を扱う
UnitTestの準備
・パッケージの導入
 pubspec.yamlにflutter_testが追加されていること
※実際はtestパッケージがあれば良いが、
 flutter_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()でテストケースを
まとめることが出来る。
テスト実行
1. テストファイルを右クリック
2. 実行→tests in XXXX
デバッグの画面が開いて
結果が表示される
ここからが本題
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();
}
}
テストを行いやすくするために
・初期化が必要な外部パッケージ(DBやFirebaseなど)を
 直接使っているclassはテストしにくい。
※できないわけではない: cloud_firestore_mocks (https://pub.dev/packages/cloud_firestore_mocks)
・でもビジネスロジックなので、データをUI等から受け取って、
 加工して追加・更新 という処理になるのは必至では・・・?
テストを行いやすくするために
・初期化が必要な外部パッケージ(DBやFirebaseなど)を
 直接使っているclassはテストしにくい。
※できないわけではない: cloud_firestore_mocks (https://pub.dev/packages/cloud_firestore_mocks)
・でもビジネスロジックなので、データをUI等から受け取って、
 加工して追加・更新 という処理になるのは必至では・・・?
解決策:分離して疎結合にする!
ソフトウェアアーキテクチャの話
ビジネスロジックの集約と依存関係の分離
・例えばUIのクラスにすべてのロ
ジックを詰め込んで実装すること
もできるが、それをやってしまうと
一つのクラスが肥大化してしまう



・肥大化したクラスはメンテナンス
しづらく、読みにくく、テストしにく
い

Robert C. Martin著 Clean Architecture 達人に学ぶソフトウェアの構造と設計
ビジネスロジックの集約と依存関係の分離
・例えばUIのクラスにすべてのロ
ジックを詰め込んで実装すること
もできるが、それをやってしまうと
一つのクラスが肥大化してしまう



・肥大化したクラスはメンテナンス
しづらく、読みにくく、テストしにく
い

Robert C. Martin著 Clean Architecture 達人に学ぶソフトウェアの構造と設計
テスタブルなコードにするために
考えを少しだけ取り入れることにする
オブジェクト指向の話
・オブジェクト指向とは何か?と言う話はしません
・オブジェクト指向っぽいプログラミングのテクニックを少し使って
 今までテストできなくなってしまっていたコードを
 リファクタリングすることに注力します
キーワード:インターフェース、実装、抽象クラス、継承
【Flutter】Providerで最低限のDIを行ってテスタブルなコードにリファクタリングする
https://qiita.com/tokkun5552/items/7af34769104e94f50745
インターフェース
・メソッドの実装を強制する仕組み
・メソッドだけ定義して実際の処理は書かない
→内部で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を使う処理)はリ
ポジトリーというクラスに集約して、
ビジネスロジックから切り離す
ビジネスロジックはインターフェースに
依存する。(具体的な実装に依存しない。)
では、実際の処理はどう呼ばれる?
依存関係とDIの話
・ここで登場するのがDI(Dependency Injection)
 よく「依存性の注入」と訳されるが、
 「依存オブジェクトの注入」と考えた方が分かりやすい。
・先の例のように依存するオブジェクトを
 コンストラクタなどで外から受け取るように実装する
依存関係とDIの話
TodoItemDetailModel
field TodoItemRepository
Interface TodoItemRepository
TodoItemRepositoryImpl
依存
注入
実装
ProviderでDIする
・注入の処理をまとめて書くためのクラスをDIコンテナーと言う
・Flutterの場合get_itというDIコンテナーがある
get_it | pub.dev : https://pub.dev/packages/get_it
・get_itを使っても良いが、Providerの機能でもDIのようなことは出来るので、
 今回はProviderを使う
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
Flutterにおけるcontext
・FlutterはWidgetをツリー状に連ねて書いていく
・そのツリーの中で自分が今いる場所を表す
・HogeWidgetのbuildで引数として受け取るBuildContextには
 Scaffoldやそれより先祖の情報は入っているが、
 FugaWidgetの情報は入ってない
・今回の例だとcontext.read<>()を記載するよりも
 先祖でProviderを呼ばなければいけない
main
Material
App
Scaffold
Hoge
Widget
FugaW
idget
DIを行うと、テストの時に差し替えられる
・アプリで使用するリポジトリの実装
 と、テストでの実装を別々に書ける
・test側の実装は、DBを呼ばず
 インメモリのDBとして記述する
・XXX_test.dartの中で直接渡せばよい
テスト用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();
テスタブルなコードにするまとめ
・テストを行いたいclassから依存オブジェクトを排除する。
 例:Firestore 、 FirebaseAuth、sqflite 、SharedPreferences
・上記のようなものを扱うRepositoryを定義して、
 インターフェースと実装に分ける
・Providerでrepositoryの実装をコンストラクタで渡す
・テスト用のrepositoryを書く
テスト実行を自動化しよう
テスト自動化のメリット
・ローカルで毎回テストしてもいいが面倒くさい
・テストの結果がGitHub上に残る
 ・後になってバグが見つかった時に、どの時点まで正しかったのか追える
・プルリクをレビューする際の目安になる
 ・ビジネスロジックのレビューは単体テストだけ見れば正しいかどうかわかる
 ・ロジックに考慮漏れがあれば、レビュアーがテストを追加して
  担当者がテストを通過できるように修正する とかも可能
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
Codecovやlcovでカバレッジの可視化
・右画像のように、テスト出来ていない
ところが分かる
・やり方は以下
https://qiita.com/tokkun5552/items/2eb6793501c152dabf
33#codecov%E3%81%A7%E3%82%AB%E3%83%90%E3%83
%AC%E3%83%83%E3%82%B8%E5%8F%AF%E8%A6%96%E5
%8C%96
・実際の画面を見てみましょう
まとめ
・単体テストの基本的な書き方と実行の仕方が分かった
・modelから外部処理をRepositoryに分離する方法が分かった
・RepositoryをProviderでDIする方法が分かった
・GitHub Actionsでテストを自動実行する方法が分かった
・Codecovでカバレッジを可視化する方法がわかった
ご清聴ありがとうございました

Flutterで単体テストを行う方法とGitHub Actionsを使った自動化