😎

【Flutter/Riverpod/Mockito】MVVM + RepositoryのUnitテストを検討する

2024/06/21に公開

ディレクトリ構成

lib
lib 
    ├─ model 
  │  ├ user.dart
  │  ├ user.freezed.dart
  │  └ user.g.dart
  │
    ├─ repository
  │  ├ api_client.dart
  │  └ users_repository.dart
  │
    ├─ controller 
  │  ├ user_list_page_controller.dart
  │  ├ user_list_page_controll.freezed.dart
  │  ├ user_list_page_state.dart
  │  └ user_list_page_state.freezed.dart
  │
  ├─ page
  │  └ user_list_page.dart
test
test 
    ├─ model 
  │  └ user_test.dart
  │
    ├─ repository
  │  ├ users_repository_test.dart
  │  └ users_repository_test.mocks.dart
  │
    ├─ controller 
  │  ├ user_list_page_controller_test.dart
  │  ├ user_list_page_controller_test.mocks.dart
  │  ├ user_list_page_state.dart
  │  └ user_list_page_state.freezed.dart
  │
  ├─ page
  │  ├ mock_user_list_page_controller.dart
  │  ├ mock_user_list_page_controller.g.dart
  │  └ user_list_page.dart

https://github.com/NoriakiSakata/unit_test_practice

パッケージ

Mockito
flutter_riverpod
freezed
dio

テストコード

Model

実装コード

class User with _$User {
  factory User({required int id, required String name}) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
テストコード
void main() {
  test("user model test", () async {
    final user = User.fromJson(UserMockdata.json);
    expect(user.id, 1);
    expect(user.name, 'Leanne Graham');
  });
}

Repository

実装コード
abstract class UsersRepositoryInterface {
  Future<List<User>?> fetchUsers();
}

class UsersRepository implements UsersRepositoryInterface {
  final ApiClient _apiClient;

  UsersRepository({ApiClient? apiClient})
      : _apiClient = apiClient ?? ApiClient();

  
  Future<List<User>?> fetchUsers() async {
    final datas = await _apiClient.get('/users');
    if (datas is List<dynamic>) {
      return datas.map((data) => User.fromJson(data)).toList();
    }
    return null;
  }
}
テストコード
([MockSpec<ApiClient>()])
void main() {
  test(
    'user repository test',
    () async {
      final mockApiClient = MockApiClient();
      final repository = UsersRepository(apiClient: mockApiClient);

      when(mockApiClient.get('/users'))
          .thenAnswer((realInvocation) async => UserMockdata.list);

      expect(
        await repository.fetchUsers(),
        [User(id: 1, name: 'Leanne Graham')],
      );
    },
  );
}

Controller

実装コード

class UserListPageController extends _$UserListPageController {
  UserListPageController({UsersRepository? repository})
      : _repository = repository ?? UsersRepository();
  final UsersRepository _repository;

  
  Future<UserListPageState> build() async {
    return UserListPageState(users: await fetchUsers());
  }

  Future<List<User>?> fetchUsers() async {
    return await _repository.fetchUsers();
  }
}
テストコード
([MockSpec<UsersRepository>()])
void main() {
  test(
    'user list page controller test',
    () async {
      final UsersRepository fakeRepository = MockUsersRepository();

      final controller = UserListPageController(repository: fakeRepository);

      final mockData = [
        User(id: 1, name: 'test1'),
        User(id: 2, name: 'test2'),
        User(id: 3, name: 'test3')
      ];

      when(fakeRepository.fetchUsers())
          .thenAnswer((realInvocation) async => mockData);

      expect(
        await controller.fetchUsers(),
        mockData,
      );
    },
  );
}

Page

実装コード
class ListPage extends ConsumerWidget {
  const ListPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final data = ref.watch(userListPageControllerProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('ユーザー一覧'),
      ),
      body: Center(
        child: data.when(
          data: (state) {
            final users = state.users;
            if (users == null || users.isEmpty) {
              return const Text('リストが空です');
            }
            return ListView.builder(
              itemCount: users.length,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(users[index].name),
                );
              },
            );
          },
          error: (o, s) => const Text(
            'データの取得に失敗しました',
            style: TextStyle(color: Color.fromARGB(255, 165, 142, 140)),
          ),
          loading: () => const CircularProgressIndicator(),
        ),
      ),
    );
  }
}
テストコード
void main() {
  final UserListPageController fakeController = MockUserListPageController();

  testWidgets('UserListPage test', (WidgetTester tester) async {
    final container = ProviderContainer(
      overrides: [
        userListPageControllerProvider.overrideWith(() => fakeController)
      ],
    );
    await tester.pumpWidget(
      UncontrolledProviderScope(
        container: container,
        child: const MaterialApp(
          home: ListPage(),
        ),
      ),
    );

    await tester.pumpAndSettle();

    expect(find.text('test'), findsOneWidget);
    expect(find.text('ユーザー一覧'), findsOneWidget);
  });
}

モックControllerを別ファイルで定義する。

part 'mock_user_list_page_controller.g.dart';


class MockUserListPageController extends _$MockUserListPageController
    with Mock
    implements UserListPageController {
  
  Future<UserListPageState> build() async {
    return UserListPageState(users: [User(id: 1, name: 'test')]);
  }
}

解説

model

final user = User.fromJson(UserMockdata.json);

モックデータをfromJsonメソッドに渡す。

expect(user.id, 1);
expect(user.name, 'Leanne Graham');

jsonをコンバートできるか確認する。

repository

([MockSpec<ApiClient>()])

ApiClientのモックを自動生成する。

final mockApiClient = MockApiClient();
final repository = UsersRepository(apiClient: mockApiClient);

モックApiClientをRepositoryにセットする。

when(mockApiClient.get('/users'))
    .thenAnswer((realInvocation) async => UserMockdata.list);

モックApiClientのレスポンスを定義する。

expect(
   await repository.fetchUsers(),
   [User(id: 1, name: 'Leanne Graham')],
);

fetchUsersをテスト
レスポンスが一致するか確認する。

controller

([MockSpec<UsersRepository>()])

Repositoryのモックを自動生成する。

final UsersRepository fakeRepository = MockUsersRepository();
final controller = UserListPageController(repository: fakeRepository);

モックRepositoryをControllerにセットする。

final mockData = [
  User(id: 1, name: 'test1'),
  User(id: 2, name: 'test2'),
  User(id: 3, name: 'test3')
];

when(fakeRepository.fetchUsers())
    .thenAnswer((realInvocation) async => mockData);

モックRepositoryのfetchUsersのレスポンスを定義する。

expect(
    await controller.fetchUsers(),
    mockData,
);

fetchUsersをテストする。
レスポンスが一致するか確認する。

page

part 'mock_user_list_page_controller.g.dart';


class MockUserListPageController extends _$MockUserListPageController
    with Mock
    implements UserListPageController {
  
  Future<UserListPageState> build() async {
    return UserListPageState(users: [User(id: 1, name: 'test')]);
  }
}

riverpod generatorで作成したcontrollerはモックの自動生成ができないので
別ファイルにモックcontrollerを作成する。

final UserListPageController fakeController = MockUserListPageController();

モックControllerを初-

final container = ProviderContainer(
  overrides: [
    userListPageControllerProvider.overrideWith(() => fakeController)
  ],
);

ProviderContainerを作成する。
モックControllerでオーバーライドする。

await tester.pumpWidget(
      UncontrolledProviderScope(
        container: container,
        child: const MaterialApp(
          home: ListPage(),
        ),
      ),
    );
```dart
UncontrolledProviderScopeMaterialAppをラップする。
ProviderContainerをセットする。


```dart
await tester.pumpAndSettle();

非同期処理の結果を待つ。

expect(find.text('test'), findsOneWidget);
expect(find.text('ユーザー一覧'), findsOneWidget);

ユーザー名のtestが表示されることを確認する。
タイトルのユーザー一覧が表示されることを確認する。

Discussion