[Flutter x Riverpod x hive] 簡単なブックマークアプリとWidget Test、Integration Test

# 作成したブックマークアプリ

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Flutter x Riverpod x HiveのBookmarkアプリ <a href="https://t.co/diaCF4wlig">pic.twitter.com/diaCF4wlig</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1487700580877287424?ref_src=twsrc%5Etfw">January 30, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

https://github.com/popy1017/flutter_bookmark

# ポイント
## アプリ
### URLからページのタイトルやサムネ画像を取得する
過去に記載↓

https://qiita.com/popy1017/items/732229abc14e73933269

### Riverpod x hive
hiveはBoxアクセス前に`Hive.openBox`(非同期)を実行する必要があるため`FutureProvider`で宣言。（いろいろ試したがこれが一番しっくりきた）

```dart:bookmark_repository.dart
final bookmarkRepositoryProvider =
    FutureProvider((ref) => BookmarkRepository.open());

class BookmarkRepository {
  late Box<Bookmark> _box;

  Box<Bookmark> get box => _box;

  List<Bookmark> get bookmarks => _box.values.toList();

  Stream<BoxEvent> get stream => _box.watch();

  static Future<BookmarkRepository> open() async {
    final repos = BookmarkRepository();
    await repos._initHive();
    return repos;
  }

  Future<BookmarkRepository> _initHive() async {
    Hive.registerAdapter(BookmarkAdapter());
    _box = await Hive.openBox('bookmark');
    return this;
  }

  ~~
}
```

#### View部分

hiveにはDBの変更を検知するためのメソッド(`box.listenable()`)が用意されており、それを`ValueListenableBuilder`と組み合わせて簡単に変更を検知することができるが、`ValueListenableBuilder`のテスト方法がわからなかったので`box.watch()`と`StreamBuilder`で変更を検知することとした。

```dart:bookmark_list.dart
class BookmarkList extends ConsumerWidget {
  const BookmarkList({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final _asyncBookmarkRepository = ref.watch(bookmarkRepositoryProvider);
    return _asyncBookmarkRepository.when(
      data: (BookmarkRepository _bookmarkRepository) {
        // ValueListenableBuilderとbox.listenable()でも変更を検知できるが
        // テストしづらいためStreamBuilderとbox.watch()を採用
        return StreamBuilder(
          stream: _bookmarkRepository.stream,
          builder: (_, AsyncSnapshot<BoxEvent> snapshot) {
            ~~
          },
        );
      },
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (_, __) => const Center(child: CircularProgressIndicator()),
    );
  }
}
```



#### 参考
https://stackoverflow.com/questions/68435432/flutter-integrating-hive-database-with-riverpod

## テスト
### Widget test
#### Repositoryのモック化
mockitoを使い`BookmarkRepository`をモック化。テストに必要な関数を`override`。

https://pub.dev/packages/mockito

サムネ画像のURLはOGPを確認できるサイトや、サイトのhtmlから確認。（考えてみればどうせ表示できないので適当でもいいかもしれない。。。）

https://rakko.tools/tools/9/

```dart:test/home_test.dart
class MockBookmarkRepository extends Mock implements BookmarkRepository {
  final List<Bookmark> _bookmarks = [
    Bookmark(
      id: '123',
      url: 'https://flutter.dev/',
      title: 'Flutter',
      description: 'Flutter web site',
      imageUri:
          'https://storage.googleapis.com/cms-storage-bucket/70760bf1e88b184bb1bc.png',
    ),
    Bookmark(
      id: '456',
      url: 'https://www.famitsu.com/news/202201/28249315.html',
      title: 'Famitsu',
      description: 'Famitsu web site',
      imageUri:
          'https://www.famitsu.com/images/000/249/315/z_61f265395b2da.jpg',
    )
  ];

  final StreamController<BoxEvent> _streamController =
      StreamController<BoxEvent>();

  @override
  List<Bookmark> get bookmarks => _bookmarks;

  @override
  Stream<BoxEvent> get stream => _streamController.stream;

  @override
  Future<void> create(Bookmark bookmark) async {
    _bookmarks.add(bookmark);

    // Box変更イベントを発火（BoxEventの中身は使わないのでなんでもよい）
    _streamController.add(BoxEvent(bookmark.id, '', true));
  }
}
```
#### モック化したRepositoryクラスを使ってテストする

モック化したRepositoryクラスで新たにProviderを作成し、`overrideWithProvider(モックProvider)`で上書きする。

```dart:test/home_test.dart
void main() {
  final BookmarkRepository _fakeRepository = MockBookmarkRepository();
  late FutureProvider<BookmarkRepository> _fakeRepositoryProvider;

  setUp(() async {
    await initialiseHive();
    // モック化したRepositoryクラスで新たにProviderを作成。
    _fakeRepositoryProvider = FutureProvider((ref) async { 
      return _fakeRepository;
    });
  });

  testWidgets('登録されているBookmarkが表示される', (WidgetTester tester) async {
    // NetworkImageがエラーになるのでモック化
    mockNetworkImagesFor(() async {
      await tester.pumpWidget(MaterialApp(
        home: ProviderScope(
          child: Home(),
          overrides: [
            // `overrideWithProvider(モックProvider)`で上書きする
            bookmarkRepositoryProvider.overrideWithProvider(_fakeRepositoryProvider),
          ],
        ),
      ));
  ~~
```
#### `Image.network`をモック化する
Widgetテストではhttp通信が入る部分はエラーになるので、`network_image_mock`パッケージを使ってモック化する必要がある。
当然URLからタイトルやサムネイル画像を取得する部分もエラーになるので注意が必要（今回はその部分はテストしない）。

https://pub.dev/packages/network_image_mock

```dart
  ~~ 
  testWidgets('登録されているBookmarkが表示される', (WidgetTester tester) async {
    // Image.networkがエラーになるのでモック化
    mockNetworkImagesFor(() async {
      await tester.pumpWidget(MaterialApp(
        home: ProviderScope(
          child: Home(),
  ~~
```

### Integration Test
基本的には公式のやり方に沿う。(シミュレーターで動きが見れるのでWidget Testより簡単かもしれない)

https://docs.flutter.dev/cookbook/testing/integration/introduction#5-run-the-integration-testnetwork_image_mock

#### 自動でスワイプする
今回作成したアプリでは左にスワイプでブックマークを削除する機能があるが、`tester.fling`という関数で実現できた。(`drag`だと`tap`判定になってしまいWebViewが開いてしまったが、頑張れば`drag`でもできるかもしれない）

```dart:integration_test/home_test.dart
    // ブックマークが表示されるか確認
    final bookmark = find.byType(BookmarkCard);
    expect(bookmark, findsOneWidget);

    // ブックマークを左にスワイプ
    await tester.fling(bookmark, Offset(-1000, 0), 5000);
    await tester.pumpAndSettle();

    // ブックマークが消えているか確認
    expect(bookmark, findsNothing);
```

#### Hive関連のエラー
Integration testが複数ある場合、`Hive.registerAdapter()`が複数回呼ばれてエラーとなってしまうが`Hive.registerAdapter(HogeAdapter, override: true)`とすれば上書きする設定にできるので対処可能。

```dart
Future<void> main({bool isTesting = false}) async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // テスト中は複数回実行されてしまうのでoverrideを許可する
  Hive.registerAdapter(BookmarkAdapter(), override: isTesting);
```

### Github Actionsで自動化
https://goyoki.hatenablog.com/entry/2021/01/23/011844

#### 端末IDを取得する
端末IDを取得しシミュレーターを起動する部分だが、`xcrun instruments`が非推奨になっているらしく`xcrun xctrace list devices`が代わりに使える。ゴリ押しだが以下のように変更した(awkとかsedとか難しい。。)。

```
          UDID=$(
            xcrun xctrace list devices | grep -m 1 "iPhone 13" | sed -e "s/^.*(\(.*\)).*$/\1/"
          )
          xcrun simctl boot "${UDID:?No Simulator with this name found}"
```