🤖

[Flutter] riverpodを使ったViewModelのテストを書こう!

2024/09/19に公開

本記事では、Flutter(Dart)における、riverpodを使ったviewmodelのテストについてみていきたいと思います💡

こんな人におすすめ

・Flutterでユニットテストを書いてみたい
・riverpodを使ったテストを書いてみたい
・viewmodelのテスト手法を知りたい

viewmodelの単体テストとは

まず、ユニットテストレベルでは以下のテストを行います。

・状態ベースのテスト
・出力ベースのテスト

ですが、viewmodelの場合は、状態ベースのテストをすることが比較的多いです。
そのため、state がきちんと予測した状態を保っているか。
という着眼点でテストすることが多くなるでしょう。

良ければ、テストの考え方についてまとめているのでこちらを一読ください。
https://zenn.dev/renren0112/articles/88111b123029d7

Stateパターンで状態管理

@freezed
class ToDoState with _$ToDoState {
  const factory ToDoState({
    required List<ToDo> todosList,
    required TextEditingController todoController,
  }) = _ToDoState;
}

// ToDo
@freezed
class ToDo with _$ToDo {
  const factory ToDo({
    required String id,
    required String todoText,
    @Default(false) bool isDone,
  }) = _ToDo;
}

viewmodelを作成

Notifierクラスを使用しています。
以前は、StateNotiferを使用する機会がおおかったのですが、Riverpod2.0からこちらに移行となりました。

https://riverpod.dev/ja/docs/migration/from_state_notifier

@riverpod
class TodoViewmodel extends _$TodoViewmodel {
  TodoViewmodel() : super();

  @override
  ToDoState build() {
    return ToDoState(
      todosList: [],
      todoController: TextEditingController(),
    );
  }

  void changeTodo(String id) {
    final newToDoList = state.todosList.map((e) {
      if (e.id == id) {
        return e.copyWith(isDone: !e.isDone);
      }
      return e;
    }).toList();
    state = state.copyWith(todosList: newToDoList);
  }

  void deleteToDoItem(String id) {
    final newToDoList =
        state.todosList.where((element) => element.id != id).toList();
    state = state.copyWith(todosList: newToDoList);
  }

  void addToDoItem(String toDo) {
    final newToDoList = List<ToDo>.from(state.todosList)
      ..add(ToDo(
        id: const Uuid().v4(),
        todoText: toDo,
        isDone: false,
      ));
    state.todoController.clear();
    state = state.copyWith(
      todosList: newToDoList,
    );
  }
}

画面の作成

vm と state で状態を画面で一元管理できるのが魅力的ですよね!⭐️

class TodoPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // viewmodel
    final vm = ref.watch(todoViewmodelProvider.notifier);
    // 状態を画面で一元管理する
    final state = ref.watch(todoViewmodelProvider);

    return GestureDetector(
      onTap: () {
        FocusScope.of(context).unfocus();
      },
      child: Scaffold(
        body: Stack(
          children: [
            Container(
              padding: EdgeInsets.symmetric(
                horizontal: 20,
                vertical: 15,
              ),
              child: Column(
                children: [
                  const SizedBox(height: 50),
                  Expanded(
                    child: ListView(
                      children: [
                        for (ToDo todo in state.todosList.reversed)
                          Container(
                            margin: EdgeInsets.only(bottom: 20),
                            child: ListTile(
                              onTap: () {
                                vm.changeTodo(todo.id);
                              },
                              shape: RoundedRectangleBorder(
                                borderRadius: BorderRadius.circular(20),
                              ),
                              contentPadding: EdgeInsets.symmetric(
                                  horizontal: 20, vertical: 5),
                              tileColor: Colors.white,
                              leading: Icon(
                                todo.isDone
                                    ? Icons.check_box
                                    : Icons.check_box_outline_blank,
                              ),
                              title: Text(
                                todo.todoText!,
                                style: TextStyle(
                                  fontSize: 16,
                                  decoration: todo.isDone
                                      ? TextDecoration.lineThrough
                                      : null,
                                ),
                              ),
                              trailing: Container(
                                padding: EdgeInsets.all(0),
                                margin: EdgeInsets.symmetric(vertical: 12),
                                height: 35,
                                width: 35,
                                decoration: BoxDecoration(
                                  borderRadius: BorderRadius.circular(5),
                                ),
                                child: IconButton(
                                  color: Colors.red,
                                  iconSize: 18,
                                  icon: Icon(Icons.delete),
                                  onPressed: () {
                                    vm.deleteToDoItem(todo.id);
                                  },
                                ),
                              ),
                            ),
                          ),
                      ],
                    ),
                  )
                ],
              ),
            ),
            Align(
              alignment: Alignment.bottomCenter,
              child: Row(children: [
                Expanded(
                  child: Container(
                    margin: const EdgeInsets.only(
                      bottom: 20,
                      right: 20,
                      left: 20,
                    ),
                    padding: const EdgeInsets.symmetric(
                      horizontal: 20,
                      vertical: 5,
                    ),
                    decoration: BoxDecoration(
                      color: Colors.white,
                      boxShadow: const [
                        BoxShadow(
                          color: Colors.grey,
                          offset: Offset(0.0, 0.0),
                          blurRadius: 10.0,
                          spreadRadius: 0.0,
                        ),
                      ],
                      borderRadius: BorderRadius.circular(10),
                    ),
                    child: TextField(
                      controller: state.todoController,
                      decoration: const InputDecoration(
                        hintText: 'todo...',
                        border: InputBorder.none,
                      ),
                    ),
                  ),
                ),
                Container(
                  margin: const EdgeInsets.only(
                    bottom: 20,
                    right: 20,
                  ),
                  child: ElevatedButton(
                    child: Text(
                      '+',
                      style: TextStyle(
                        fontSize: 40,
                      ),
                    ),
                    onPressed: () {
                      vm.addToDoItem(state.todoController.text);
                      FocusScope.of(context).unfocus();
                    },
                    style: ElevatedButton.styleFrom(
                      minimumSize: Size(60, 60),
                      elevation: 10,
                    ),
                  ),
                ),
              ]),
            ),
          ],
        ),
      ),
    );
  }
}

テストコードを作成する

vmで定義されているメソットごとにテストを作成しました。
以下の流れでテストを書いていきます。

① Arrange フェーズで準備
② Actで vmのメソットを呼び出す
③ Assertで 状態をチェックする
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_todo/feature/todo/todo_viewmodel.dart';
import 'package:riverpod/riverpod.dart';

void main() {
  group('TodoViewmodel Tests ', () {
    late ProviderContainer container;
    late TodoViewmodel vm;

    setUp(() {
      container = ProviderContainer();
      vm = container.read(todoViewmodelProvider.notifier);
    });

    tearDown(() {
      container.dispose();
    });

    test('addToDoItemでリストに1つ追加されていること', () {
      // Arrange
      const todoText = 'Test ToDo';

      // Act
      vm.addToDoItem(todoText);

      // Assert
      final todos = container.read(todoViewmodelProvider).todosList;
      expect(todos.length, 1);
      expect(todos.first.todoText, todoText);
      expect(todos.first.isDone, false);
    });

    test('deleteToDoItemで追加されたToDoが削除されること', () {
      // Arrange
      const todoText = 'Test ToDo';
      vm.addToDoItem(todoText);
      final addedToDo = container.read(todoViewmodelProvider).todosList.first;

      // Act
      vm.deleteToDoItem(addedToDo.id);

      // Assert
      final todos = container.read(todoViewmodelProvider).todosList;
      expect(todos.isEmpty, true);
    });

    test('changeTodoで完了状態に変わること', () {
      // Arrange
      const todoText = 'Test ToDo';
      vm.addToDoItem(todoText);
      final addedToDo = container.read(todoViewmodelProvider).todosList.first;

      // Act
      vm.changeTodo(addedToDo.id);

      // Assert
      final updatedToDo = container.read(todoViewmodelProvider).todosList.first;
      expect(updatedToDo.isDone, true);
    });
  });
}

まとめ

いかがだったでしょうか。
こちらは、依存注入が無く楽に書いていますが、本番ではもっと複雑になると思います。

しかし viewmodelのテストの本質は変わらないと思います。
また、ユニットテストレベルでは、フィードバックの速さを重視しています。
そのため、

ここで エラーダイアログが呼ばれるはず

などのコミュニケーションベースのテストはE2Eテストでおこなう方針としています。

参考になれば幸いです。

Discussion