前回の記事の最後に書きましたが、テストを行う際はclass同士の依存関係が密になっていると、思うようにテストを行うことが出来ません。
これは、ビジネスロジックを記載している部分にDBのインスタンスやFirebaseのインスタンスを持たせてしまうと、初期化処理などが行えないためです。(※シミュレータなどを使って回避することもできるようです)
そこで、今回は前回と同じこちらのサンプルアプリをリファクタリングして、テスタブルなコードに書き換えてみました。
やったこと
- FlutterでのProviderを用いたTodoアプリをリファクタリング
- Providerを使ってDIする
- test用のrepositoryを自作する
最終的なコードはこちらです。
前回の記事時点での問題点
前回の結論でも書きましたがビジネスロジックを持っているmodelがリポジトリに依存していて、さらにリポジトリの中でDBのインスタンスを取得しています。
class TodoItemRepository {
static String table = 'todo_item';
static DatabaseProvider instance = DatabaseProvider.instance;
このように書いてしまうと、以下のような問題があります。
- DBのイニシャライズがしにくい(多分出来ない)
- 何らかの方法でテスト実行時にDBが作れたとしても、結果として色々な場所を同時にテストすることになり、問題の切り分けがしにくい
これはRealmなどのローカルDBだけでなくFirestoreなどを使用している場合も同様です。
これを解決するために、DI(Dependency Injection)を取り入れてリファクタリングを行いました。
DIとは?
DIとはDependency Injectionの略で、そのまま日本語にすると依存性の注入です。
詳細については下記の参考を参照ください。
参考:
DI・DIコンテナ、ちゃんと理解出来てる・・? - Qiita
依存性の注入 - Wikipedia
端的に説明するならば、依存関係を取り除き、外部から受け取るようにすることです。
例えば以下のようなclassがあったとします。
class Hoge{
final _fuga = Fuga();
_foo;
void getFoo() {
_foo = _fuga.getFoo();
}
class Fuga{
final _db = HogeHogeDB();
Foo getFoo() {
return _db.getFoo();
}
Hoge
クラスの中でFuga
のコンストラクタを呼び、インスタンスを作成しています。
これをHogeクラスはFugaに依存しているといいます。
これが例えばFuga
クラスがDBやAPIを呼ぶクラスだった場合、テストが非常にしにくくなってしまいます。
そこでまず外からFuga
クラスを渡すようにします。
import '../fuga.dart';
class Hoge {
Hoge ({
@required Fuga fuga,
}) : _fuga = fuga;
Fuga _fuga;
_foo;
void getFoo() {
_foo = _fuga.getFoo();
}
Fuga
を受け取って内部の_fuga
で参照するようにします。
しかしこれだけでは結局Fuga
に直接依存してしまうので、Fuga
の内部でDBを生成することになり単体テストがしやすくなっていません。
そこでFuga
クラスをインターフェースと実装に分離します。
abstract class Fuga{
Foo getFoo();
}
class FugaImpl implements Fuga{
final _db = HogeHogeDB();
@override
Foo getFoo() {
return _db.getFoo();
}
dartにはinterfaceがないので、abstractクラスとして宣言し、インターフェースのように使います。
そしてHoge
クラスはabstractの方のFugaに依存するように書き換えます。(上記のHoge
はFuga
に依存していたので実際は書き換えなくてもabstractに依存するようになりました)
ではこのFugaImpl
はどこで使うのでしょうか?実際のアプリ上ではこちらを使わないとdbからの読み込みが出来ないです。
そこで最後にProviderでHoge
のコンストラクタに渡してあげます。
void main() {
runApp(
MultiProvider(
providers: [
Provider<Fuga>(
create: (_) => FugaImpl (),
),
],
child: App(),
),
);
}
こうすることでApp
以下のcontext
からFuga
というキーワードでFugaImpl
を呼べるようになります。
実際に使う際にはChangeNotifierProviderやCosumerなどで
create: (_) => Hoge(
fuga: context.read<Fuga>(),
)
としてやることで、Hoge
にFuga
を実装したFugaImpl
を渡すことができます。
リファクタリング- 依存関係を分離する
では実際に前回の記事で作成したTODOアプリのサンプルをリファクタリングしてみます。
サンプルアプリの主な構成は以下のようになっています。
テストをすべてに対して書いても労力の割には品質に貢献しないということで、こちらの記事を参考に、単体テストはビジネスロジックに関してのみ行うのが効率が良いようなので、それを念頭に置いてリファクタリングを行いました。
結果DDD(Domain Driven Design)とMVVMの間のような形に落ち着きました。
サンプルアプリはもともとmodelの中でdbを生成するようなことはしていないので、repositoryの分離を行いました。
※modelの中でdbやfirebaseの処理を記述してしまっている場合は、repository_impl側に持たせるようにしてください。
元々書いていたメソッドを抽出してabstractクラスを作ります。
abstract class TodoItemRepository {
Future<TodoItem> create(String title, String body, bool isDone, DateTime now);
Future<List<TodoItem>> findAll({bool viewCompletedItems});
Future<TodoItem> find(int id);
Future<void> update(TodoItem todoItem);
Future<void> updateIsDoneById(int id, bool isDone);
Future<void> delete(int id);
}
そして、実際の処理は上記のTodoItemRepository
をimplements
して、全てのメソッドをoverride
します。
extendsとimplementsの違いは以下を参考にしてください。
Dart 継承を理解してみる - Qiita
class TodoItemRepositoryImpl implements TodoItemRepository {
static String table = 'todo_item';
static DatabaseProvider instance = DatabaseProvider.instance;
@override
Future<TodoItem> create(
String title, String body, bool isDone, DateTime now) async {
final Map<String, dynamic> row = {
'title': title,
'body': body,
'createdAt': now.toString(),
'updatedAt': now.toString(),
'isDone': (isDone == true) ? 1 : 0,
};
final db = await instance.database;
final id = await db.insert(table, row);
return TodoItem(
id: id,
title: row["title"],
body: row["body"],
createdAt: now,
updatedAt: now,
isDone: isDone,
);
}
.
.
.
そしてmodel側でabstractの方を受け取るようにコンストラクターを書き換え、同時にmainでProviderを使って生成しておきます。
class TodoItemDetailModel extends ChangeNotifier {
TodoItemDetailModel({
@required TodoItemRepository todoItemRepository,
}) : _todoItemRepository = todoItemRepository;
TodoItemRepository _todoItemRepository;
void main() {
runApp(
MultiProvider(
providers: [
Provider<StorageRepository>(
create: (_) => StorageRepositoryImpl(),
),
Provider<TodoItemRepository>(
create: (_) => TodoItemRepositoryImpl(),
)
],
child: App(),
),
);
}
※SharedPreferenceでローカルに情報を保管するStorageRespositoryについてもDIしています。
これでTodoItemDetailModel
はDBに一切依存しなくなりました。
注意として、メソッドで渡す引数や受け取る返り値に関しても、パッケージ固有のものは使わないようにしてください。
dart
のプリミティブ型やdomain
の中で定義したエンティティモデルのみを使います。
つまりrepository
の実装側でそれらに変換してあげます。
テストコードを書く
テストを書くにはもう1つ工夫が必要です。
DBをモデルから分離したので、テスト用のrepository
を書いてあげます。
以下にコードを記載しますが、ほとんど同じように使えるはずです。
class TodoItemRepositoryMemImpl implements TodoItemRepository {
// { id : TodoItem }
final _data = <int, TodoItem>{};
// Repository内部やDB側で自動生成されるものを外部から操作できるようにする
int _currentId = 0;
int get currentId => _currentId;
void incrementId() {
_currentId++;
}
void clear() {
_data.clear();
_currentId = 0;
}
@override
Future<TodoItem> create(
String title, String body, bool isDone, DateTime now) {
final result = TodoItem(
id: _currentId,
title: title,
body: body,
isDone: isDone,
createdAt: now,
updatedAt: now,
);
_data[_currentId] = result;
return Future.value(result);
}
@override
Future<void> delete(int id) {
_data.remove(id);
return null;
}
@override
Future<List<TodoItem>> findAll({bool viewCompletedItems = true}) {
List<TodoItem> result = [];
final List<TodoItem> todoItems = List<TodoItem>.unmodifiable(_data.values);
if (!viewCompletedItems) {
todoItems.forEach((element) {
result.add(element);
});
} else {
result = todoItems;
}
return Future.value(result);
}
@override
Future<TodoItem> find(int id) {
return Future.value(_data[id]);
}
@override
Future<void> updateIsDoneById(int id, bool isDone) {
final todoItem = _data[id];
todoItem.isDone = isDone;
_data[id] = todoItem;
return null;
}
@override
Future<void> update(TodoItem todoItem) {
TodoItem updateData = _data[todoItem.id];
updateData.title = todoItem.title;
updateData.body = todoItem.body;
updateData.updatedAt = todoItem.updatedAt;
_data[todoItem.id] = updateData;
return null;
}
}
Map
型でデータを保持し、各メソッドをoverride
します。
以下のようなことに注意して記載してください。
-
Map
はList<TodoItem>.unmodifiable(_data.values)
のようにしてList
に変換する -
Future
で返しているときは、return Future.value(result)
のようにラップして返却する
今回はRealm
側でid
を付与するようにしているので、テストDBも_currentId
を定義して実際のテストの中でid
を操作できるようにしておきます。
これで実際にテストが書けるようになりました。
void main() {
final TodoItemRepositoryMemImpl repository = TodoItemRepositoryMemImpl();
final TodoItemDetailModel model =
TodoItemDetailModel(todoItemRepository: repository);
final dummyDate = DateTime.now();
group('add', () {
test('正常系', () async {
// 事前準備
repository.clear();
final data = TodoItem(
id: 0,
title: 'テストタイトル',
body: 'テストボディ',
createdAt: dummyDate,
updatedAt: dummyDate,
);
model.todoTitle = data.title;
model.todoBody = data.body;
// メソッド実行
bool isSuccessful = true;
try {
await model.add();
} catch (e) {
isSuccessful = false;
}
// 結果確認
final result = await repository.findAll();
expect(isSuccessful, true);
expect(result.length, 1);
final item = result.first;
expect(item.title, data.title);
expect(item.body, data.body);
expect(item.isDone, false);
expect(item.createdAt, isNotNull);
expect(item.updatedAt, isNotNull);
expect(item.createdAt, item.updatedAt);
});
});
.
.
.
以下のように直接テスト用のrepositoryを生成して、modelに渡しています。
final TodoItemRepositoryMemImpl repository = TodoItemRepositoryMemImpl();
final TodoItemDetailModel model =
TodoItemDetailModel(todoItemRepository: repository);
これでテストの中で直接repositoryを操作することができます。
実行&結果確認
前回の記事で書いたように実行することもできますが、テストを一つ一つ手動で実行するのは面倒です。
以下のコマンドでtestフォルダ内にあるtestをすべて実行することができます。
> flutter test
まとめ
単体テストを書くために、Providerで最小限の労力でDIを行いました。
ビジネスロジックに対して割と簡単にテストをすることが出来るようになりますし、domain
とinfrastructure
に分けて外部との処理をしっかり分離してやることでコード全体の見通しも良くなりました。
さらに次のステップとしては以下のようなことができれば良いと思いました。
- GitHubにpushするタイミングで自動的にテストを実施してほしい(CI/CD)
- カバレッジを見える化したい
そのうち実施したら記事にしようと思います。
記事にしました
【Flutter】GitHubActionsでテストと静的解析を自動化する
2021/4/9 追記
こちらの記事に関する発表を行いました。
以下が発表スライドです。