15
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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

(1)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

(2)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();
  });

(3)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

15
11
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
15
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?