LoginSignup
34
36

More than 3 years have passed since last update.

【Flutter】Providerで最低限のDIを行ってテスタブルなコードにリファクタリングする

Last updated at Posted at 2021-02-14

前回の記事の最後に書きましたが、テストを行う際はclass同士の依存関係が密になっていると、思うようにテストを行うことが出来ません。
これは、ビジネスロジックを記載している部分にDBのインスタンスやFirebaseのインスタンスを持たせてしまうと、初期化処理などが行えないためです。(※シミュレータなどを使って回避することもできるようです)
そこで、今回は前回と同じこちらのサンプルアプリをリファクタリングして、テスタブルなコードに書き換えてみました。

やったこと

  • FlutterでのProviderを用いたTodoアプリをリファクタリング
  • Providerを使ってDIする
  • test用のrepositoryを自作する

最終的なコードはこちらです。

前回の記事時点での問題点

前回の結論でも書きましたがビジネスロジックを持っているmodelがリポジトリに依存していて、さらにリポジトリの中でDBのインスタンスを取得しています。

todo_item_repository.dart
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があったとします。

hoge.dart
class Hoge{
  final _fuga = Fuga();
  _foo;

  void getFoo() {
   _foo = _fuga.getFoo();
}
fuga.dart
class Fuga{
  final _db = HogeHogeDB();

  Foo getFoo() {
   return _db.getFoo();
}

Hogeクラスの中でFugaのコンストラクタを呼び、インスタンスを作成しています。
これをHogeクラスはFugaに依存しているといいます。
これが例えばFugaクラスがDBやAPIを呼ぶクラスだった場合、テストが非常にしにくくなってしまいます。
そこでまず外からFugaクラスを渡すようにします。

hoge.dart
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クラスをインターフェースと実装に分離します。

fuga.dart
abstract class Fuga{
  Foo getFoo();
}
fuga_impl.dart
class FugaImpl implements Fuga{
  final _db = HogeHogeDB();

  @override
  Foo getFoo() {
   return _db.getFoo();
}

dartにはinterfaceがないので、abstractクラスとして宣言し、インターフェースのように使います。
そしてHogeクラスはabstractの方のFugaに依存するように書き換えます。(上記のHogeFugaに依存していたので実際は書き換えなくてもabstractに依存するようになりました)

ではこのFugaImplはどこで使うのでしょうか?実際のアプリ上ではこちらを使わないとdbからの読み込みが出来ないです。
そこで最後にProviderHogeのコンストラクタに渡してあげます。

main.dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        Provider<Fuga>(
          create: (_) => FugaImpl (),
        ),
      ],
      child: App(),
    ),
  );
}

こうすることでApp以下のcontextからFugaというキーワードでFugaImplを呼べるようになります。
実際に使う際にはChangeNotifierProviderCosumerなどで

create: (_) => Hoge(
        fuga: context.read<Fuga>(),
      )

としてやることで、HogeFugaを実装したFugaImplを渡すことができます。

リファクタリング- 依存関係を分離する

では実際に前回の記事で作成したTODOアプリのサンプルをリファクタリングしてみます。
サンプルアプリの主な構成は以下のようになっています。
todosample-flutter_class_diagramm.jpg

フォルダ構成は以下のようになっています。
フォルダ構成.png

テストをすべてに対して書いても労力の割には品質に貢献しないということで、こちらの記事を参考に、単体テストはビジネスロジックに関してのみ行うのが効率が良いようなので、それを念頭に置いてリファクタリングを行いました。
結果DDD(Domain Driven Design)とMVVMの間のような形に落ち着きました。

サンプルアプリはもともとmodelの中でdbを生成するようなことはしていないので、repositoryの分離を行いました。
※modelの中でdbやfirebaseの処理を記述してしまっている場合は、repository_impl側に持たせるようにしてください。

元々書いていたメソッドを抽出してabstractクラスを作ります。

domain/todo_item_repository.dart
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);
}

そして、実際の処理は上記のTodoItemRepositoryimplementsして、全てのメソッドをoverrideします。
extendsimplementsの違いは以下を参考にしてください。
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を使って生成しておきます。

todo_item_detail_model.dart
class TodoItemDetailModel extends ChangeNotifier {
  TodoItemDetailModel({
    @required TodoItemRepository todoItemRepository,
  }) : _todoItemRepository = todoItemRepository;
  TodoItemRepository _todoItemRepository;
main.dart
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を書いてあげます。
以下にコードを記載しますが、ほとんど同じように使えるはずです。

test/infrastracture/todo_item_repository_mem_impl
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します。
以下のようなことに注意して記載してください。

  • MapList<TodoItem>.unmodifiable(_data.values)のようにしてListに変換する
  • Futureで返しているときは、return Future.value(result) のようにラップして返却する

今回はRealm側でidを付与するようにしているので、テストDBも_currentIdを定義して実際のテストの中でidを操作できるようにしておきます。
これで実際にテストが書けるようになりました。

todo_item_detail_test.dart
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

以下のようにその場で結果を確認することができます。
test実行.png

まとめ

単体テストを書くために、Providerで最小限の労力でDIを行いました。
ビジネスロジックに対して割と簡単にテストをすることが出来るようになりますし、domaininfrastructureに分けて外部との処理をしっかり分離してやることでコード全体の見通しも良くなりました。

さらに次のステップとしては以下のようなことができれば良いと思いました。

  • GitHubにpushするタイミングで自動的にテストを実施してほしい(CI/CD)
  • カバレッジを見える化したい

そのうち実施したら記事にしようと思います。
記事にしました
【Flutter】GitHubActionsでテストと静的解析を自動化する

2021/4/9 追記

こちらの記事に関する発表を行いました。
以下が発表スライドです。

34
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
36