Flutter(Dart)でのテストまとめ

Flutter（Dart）でのテスト方法を自分の備忘も兼ねてまとめておきます。
これからFlutterでのテストをやろうと考えている方の参考になれば幸いです。

# （１）Unitテスト
Unitテストは、1つの関数、メソッド、またはクラスをテストする。
ユニットテストの目的は、様々な条件下でロジックの正しさを検証すること。

## 基本的な考え方
groupで複数のtestを束ねて、testで確認したいことをexpectで確認していく。

```counter_test.dart
import 'package:counter_app/counter.dart';
import 'package:test/test.dart';

void main() {
  group('Counterクラスのテスト', () {
    test('初期値が0であること', () {
      expect(Counter().value, 0);
    });

    test('incrementで数が1増えること', () {
      final counter = Counter();
      counter.increment();
      expect(counter.value, 1);
    });

    test('decrementで数が1減ること', () {
      final counter = Counter();
      counter.decrement();
      expect(counter.value, -1);
    });
  });
}
```

## Mockを使ったテスト
DBなどのデータ取得等を行うクラスでは実際にデータアクセスするのではなく
Mockを作成して擬似的に結果を返す形でテストを行う。

**Flutterでよく使われるMockのpackage**
mockito[モキート？]
https://pub.dev/packages/mockito

アノテーションを`@GenerateMocks([モックしたいクラス])`を記述の上、
`build_runner`でMockクラスを自動生成。
mockitoのwhenメソッドで、処理呼び出し時で返ってくる内容を定義する。
以下の場合だと、get関数を「https〜」の引数で与えると「http.〜」が返ってくる、と定義。

```album_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
@GenerateMocks([http.Client])
void main() {
  group('Album情報の取得', () {
    test('Album情報の取得が正常となること', () async {
      final client = MockClient();
      when(client
              .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
          .thenAnswer((_) async =>
              http.Response('{"userId": 1, "id": 2, "title": "mock"}', 200));
      expect(await fetchAlbum(client), isA<Album>());
    });

    test('エラーとなった場合、例外がスローされること', () {
      final client = MockClient();
      when(client
              .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
          .thenAnswer((_) async => http.Response('Not Found', 404));

      expect(fetchAlbum(client), throwsException);
    });
  });
}
```
```album.dart
Future<Album> fetchAlbum(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));
  if (response.statusCode == 200) {
    return Album.fromJson(jsonDecode(response.body));
  } else {
    throw Exception('Failed to load album');
  }
}
```

## Firebase（FlutterFire）のテスト
Firebaseを利用したテストは公式からFakeと呼ばれるFirebase ライブラリの API を実装し、
その動作をシミュレートするライブラリが用意されているようです。
https://firebase.flutter.dev/docs/testing/testing/

以下のように、通常どおりの使い方の形でinstanceを生成して
addやgetを使ったテストできます。

```fakeを使った例.dart
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
void main() {
  final instance = FakeFirebaseFirestore();
  await instance.collection('users').add({
    'username': 'Bob',
  });
  final snapshot = await instance.collection('users').get();
  print(snapshot.docs.length); // 1
  print(snapshot.docs.first.get('username')); // 'Bob'
  print(instance.dump());
}
```

## Riverpodでの単体テスト
repositoryProviderを呼び出した場合の処理をFakeRepositoryで定義しておき
ProviderContainerで、repositoryProviderの挙動をオーバーライドする。

``` ProviderContainerを使ったテスト.dart
test('override repositoryProvider', () async {
  final container = ProviderContainer(
    overrides: [
      repositoryProvider.overrideWithValue(FakeRepository())
      //todoListProviderはrepositoryProviderを経由しているのでoverride不要。
    ],
  );

  // 初期ステートが loading であることを確認
  expect(
    container.read(todoListProvider),
    const AsyncValue<List<Todo>>.loading(),
  );

  /// リクエストの結果が戻るのを待つ
  await container.read(todoListProvider.future);

  // 取得したデータをテストする
  expect(container.read(todoListProvider).value, [
    isA<Todo>()
        .having((s) => s.id, 'id', '42')
        .having((s) => s.label, 'label', 'Hello world')
        .having((s) => s.completed, 'completed', false),
  ]);
});

class FakeRepository implements Repository {
  @override
  Future<List<Todo>> fetchTodos() async {
    return [
      Todo(id: '42', label: 'Hello world', completed: false),
    ];
  }
}

final todoListProvider = FutureProvider((ref) async {
  final repository = ref.watch(repositoryProvider);
  return repository.fetchTodos();
});
``` 

## よくあるUnitテスト範囲
テストを記述する範囲として、よくあるテストのキーワードだけ置いておきます。
基本的にはこちらを満たすようなテストケースを記述していきます。

**ホワイトボックステスト**
・命令網羅 (Statement Coverage) (C0)
・分岐網羅 (Branch Coverage) (C1)

**ブラックボックステスト**
・同値分割
・限界値分析（境界値分析）

**カバレージの取り方**
ホワイトボックステストの妥当性を測る際にカバレージを取得することが多いです。

・以下のコマンドでテスト時にカバレージを取得
```
flutter test --coverage
```

・出力されたカバレージファイルをlcovで見やすいようにhtml形式で出力（lcovをbrewなどでインストールしている前提）
```
genhtml coverage/lcov.info -o coverage/html
```


# （２）Widgetテスト
Widgetテストは1つのWidgetを対象にテストします。
Widgetテストの目的は、WidgetのUIが期待どおりに見え、相互作用することを確認すること。

## 基本的な考え方
pumpWidgetでテストしたいwidgetを指定します。
以下の例では、TextがTで設定されているwidgetを探しており、
findsOneWdigetで、見つかったwidgetが1つであることをテストしています。

```mywidget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('タイトルとメッセージの表示', (tester) async {
    await tester.pumpWidget(const MyWidget(title: 'T', message: 'M'));

    final titleFinder = find.text('T');
    final messageFinder = find.text('M');

    expect(titleFinder, findsOneWidget);
    expect(messageFinder, findsOneWidget);
  });
}
```

Widgetのさまざまな状態であることを確認できるようです。詳細はこちら。
https://api.flutter.dev/flutter/flutter_test/CommonFinders-class.html


**その他、ユーザーの操作も再現したテスト方法**
- listFinderのitemFinderが見つかるまで500pxずつスクロールするテスト
```scroll_test.dart
    await tester.scrollUntilVisible(
      itemFinder,
      500.0,
      scrollable: listFinder,
    );
```

- テキスト入力のテスト

```text_input_test.dart
testWidgets('テキスト入力のテスト', (tester) async {
  await tester.pumpWidget(const TodoList());

  // 見つかったTextFieldに”hi”という文字を入力
  await tester.enterText(find.byType(TextField), 'hi');
});
```

- タップ操作のテスト
```tap_test.dart
testWidgets('タップ操作のテスト', (tester) async {
  // FloatingActionButtonをタップする。
  await tester.tap(find.byType(FloatingActionButton));

  // pump()でStateをRebuildします。
  await tester.pump();

  // タップ後の操作確認
  expect(find.text('hi'), findsOneWidget);
});
```

- スワイプ操作のテスト
```swipe_test.dart
testWidgets('スワイプ操作のテスト', (tester) async {
  // dragの操作を使ってスワイプ操作を記述
  await tester.drag(find.byType(Dismissible), const Offset(500.0, 0.0));

  // 操作によってアニメーションが起こる場合、pumpAndSettle()で継続的にStateをRebuildさせる
  await tester.pumpAndSettle();

  expect(find.text('hi'), findsNothing);
});
```

その他、可能な操作については、公式ドキュメントを参照。
https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html


## Providerを用いたWidgetテスト
pumpWidgetでWidgetを指定する際にproviderを引き渡すなどを行う。

```provider_test.dart
    await tester.pumpWidget(MultiProvider(
         providers: [
        Provider<AuthBase>(
          builder: (context) => Auth(),
        ),
        Provider<Appointments>(
          builder: (context) => Appointments(),
         )
       ],
      child: Builder(
        builder: (_) => YourWidgeToTest(),
      ),
    ),);
```

## Riverpodを用いたWidgetテスト
repositoryProviderが呼び出された場合の挙動をFakeRepositoryで定義しておき
ProviderScopeでオーバーライドしたpumpWidgetを指定する。

```riverpod_widget_test.dart
testWidgets('override repositoryProvider', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        repositoryProvider.overrideWithValue(FakeRepository())
      ],
      child: MyApp(),
    ),
  );
});

class FakeRepository implements Repository {
  @override
  Future<List<Todo>> fetchTodos() async {
    return [
      Todo(id: '42', label: 'Hello world', completed: false),
    ];
  }
}
```

## Firebaseを使ったwidgetテスト
単体テストと同様にFakeのライブラリを使う。
（必要に応じてtest用のwidgetを作って、テストしたいwidgetをオーバライドする）

その他、Firebaseのサービスを使っている場合は、
最初にfirebaseを初期化しないと、うまく動かないことがあるそうです。
```firebase_test.dart
TestWidgetsFlutterBinding.ensureInitialized();
setupFirebaseCoreMocks();
setUpAll(() async {
    await Firebase.initializeApp();
  });
```
https://stackoverflow.com/questions/63662031/how-to-mock-the-firebaseapp-in-flutter


# （３）Integrationテスト
Integrationテストではアプリ全体またはアプリの大部分をテストします。
Integrationテストの目的は、テスト対象のすべてのウィジェットとサービスが
期待どおりに連携することを物理デバイス等で確認すること。

## 基本的な考え方
テストコード自体はWidgetテストとほぼ同じで記述可能。
次の設定にて、物理デバイスまたはエミュレーターでテストを実行できるように。

1\.依存関係に追加する。

```pubspec.yaml
dev_dependencies:
  integration_test:
    sdk: flutter
```

2\.`IntegrationTestWidgetsFlutterBinding.ensureInitialized()`で
物理デバイスなどに接続する形で、app.main()よりアプリ起動してテストする。
```foo_test.dart
void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('end-to-end test', () {
    testWidgets('tap on the floating action button, verify counter',
        (tester) async {
      app.main();
      await tester.pumpAndSettle();
      expect(find.text('0'), findsOneWidget);
    });
  });
}
```

3\.以下のコマンドを実行してテスト。
```
flutter test integration_test/foo_test.dart -d <DEVICE_ID>
```

## Firestoreなどでemulatorを使ってテストする
localでemulatorを動作させてテストしたい場合は、
emulatorの設定をした上でアプリの起動を行う。

```main.dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  if (USE_FIRESTORE_EMULATOR) {
    FirebaseFirestore.instance.settings = const Settings(
        host: 'localhost:8080', sslEnabled: false, persistenceEnabled: false);
  }
  runApp(FirestoreExampleApp());
}
```

**パフォーマンスの確認**
パフォーマンスは実機で自らの手で確認することが多いので、今回は割愛。
公式に、特定のタスクの実行中にパフォーマンス タイムラインを記録し、結果の概要をローカル ファイルに保存するテストがあるので、今回はリンクを記載するだけに留めます。
https://docs.flutter.dev/cookbook/testing/integration/profiling

***

今回、Flutter(dart)でのテスト方法の概要をまとめてみました。
あとは自分が書いた個人開発のアプリに対して、ひたすらテストコードを記述してみて
いろんなパターンでのテストができるようにしていきたいと思います！

＜参考にしたサイト＞
[公式]https://docs.flutter.dev/testing
[Firebase]https://firebase.flutter.dev/docs/testing/testing/
[Riverpod]https://riverpod.dev/ja/docs/cookbooks/testing