Flutter(Dart)でのテスト方法を自分の備忘も兼ねてまとめておきます。
これからFlutterでのテストをやろうと考えている方の参考になれば幸いです。
(1)Unitテスト
Unitテストは、1つの関数、メソッド、またはクラスをテストする。
ユニットテストの目的は、様々な条件下でロジックの正しさを検証すること。
基本的な考え方
groupで複数のtestを束ねて、testで確認したいことをexpectで確認していく。
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.〜」が返ってくる、と定義。
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);
});
});
}
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を使ったテストできます。
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の挙動をオーバーライドする。
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つであることをテストしています。
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ずつスクロールするテスト
await tester.scrollUntilVisible(
itemFinder,
500.0,
scrollable: listFinder,
);
- テキスト入力のテスト
testWidgets('テキスト入力のテスト', (tester) async {
await tester.pumpWidget(const TodoList());
// 見つかったTextFieldに”hi”という文字を入力
await tester.enterText(find.byType(TextField), 'hi');
});
- タップ操作のテスト
testWidgets('タップ操作のテスト', (tester) async {
// FloatingActionButtonをタップする。
await tester.tap(find.byType(FloatingActionButton));
// pump()でStateをRebuildします。
await tester.pump();
// タップ後の操作確認
expect(find.text('hi'), findsOneWidget);
});
- スワイプ操作のテスト
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を引き渡すなどを行う。
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を指定する。
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を初期化しないと、うまく動かないことがあるそうです。
TestWidgetsFlutterBinding.ensureInitialized();
setupFirebaseCoreMocks();
setUpAll(() async {
await Firebase.initializeApp();
});
(3)Integrationテスト
Integrationテストではアプリ全体またはアプリの大部分をテストします。
Integrationテストの目的は、テスト対象のすべてのウィジェットとサービスが
期待どおりに連携することを物理デバイス等で確認すること。
基本的な考え方
テストコード自体はWidgetテストとほぼ同じで記述可能。
次の設定にて、物理デバイスまたはエミュレーターでテストを実行できるように。
1.依存関係に追加する。
dev_dependencies:
integration_test:
sdk: flutter
2.IntegrationTestWidgetsFlutterBinding.ensureInitialized()
で
物理デバイスなどに接続する形で、app.main()よりアプリ起動してテストする。
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の設定をした上でアプリの起動を行う。
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