【Flutter】ローカル DB パッケージの Isar Database のテストのはまり所と解決策
本記事は、以下の記事のテスト編です。まだ読んでいない方は先に以下の記事を読むことをオススメします!
はじめに
Isar Database のテストを書いていたらはまり所が多かったので解決策を紹介します!Isar Database だけでなくよく起こるはまり所だと思うので知っておいて損は無いと思います。誰かのお役に立てたら幸いです!
完成したテストコードは下記で公開しています!
変更履歴
変更履歴
2022/06/14 初版
2022/09/19 パッケージのバージョンを 3.0.0-dev.0
から 3.0.0
にアップデートし、内容も追従
環境
Flutter 3.3.2 • channel stable • https://github.com/flutter/flutter.git
Framework • revision e3c29ec00c (4 days ago) • 2022-09-14 08:46:55 -0500
Engine • revision a4ff2c53d8
Tools • Dart 2.18.1 • DevTools 2.15.0
Isar Database のテストのはまり所と解決策
path_provider
はテストでは使えない
はまり所 1️⃣ 何も考えず、次のようにテストコードの中で path_provider
の getApplicationSupportDirectory()
を使うとエラーが発生します。
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Isar isar;
late MemoRepository repository;
setUp(() async {
final dir = await getApplicationSupportDirectory(); //ここでエラー
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
isar = await Isar.open(
[
CategorySchema,
MemoSchema,
],
directory: dir.path,
);
// カテゴリの初期値を書き込む
await isar.writeTxn(() async {
await isar.clear();
await isar.categorys.putAll(
['仕事', 'プライベート', 'その他'].map((name) => Category()..name = name).toList(),
);
});
repository = MemoRepository(isar);
});
tearDown(() async {
await isar.close(deleteFromDisk: true);
repository.dispose();
});
group('MemoRepository', () {
test('カテゴリを検索できるはず', () async {
final categories = await repository.findCategories();
expect(categories.length, 3);
});
});
}
MissingPluginException(No implementation found for method getApplicationSupportDirectory on channel plugins.flutter.io/path_provider_macos)
MethodChannel._invokeMethod
LateInitializationError: Local 'isar' has not been initialized.
dart:_internal LateError._throwLocalNotInitialized
main.<fn>
main.<fn>
path_provider
パッケージはテストでは使えません。2つの対策案をご紹介します!
対策案 1️⃣ テスト用のディレクトリを独自に作成する
getApplicationSupportDirectory()
は使わずに、テスト用のディレクトリを作成してそのパスを与えてあげます。泥臭い方法ですね。
+import 'dart:io';
+import 'dart:math';
+import 'package:path/path.dart' as path;
・・・
+ late Directory dir;
setUp(() async {
- final dir = await getApplicationSupportDirectory();
+ // 9桁のランダムな数字を生成する(例:355017887)
+ final name = Random().nextInt(pow(2, 32) as int);
+ dir = Directory(
+ path.join(
+ Directory.current.path,
+ '.dart_tool',
+ 'test',
+ 'application_support_$name',
+ ),
+ );
+ await dir.create(recursive: true);
isar = await Isar.open(
[
CategorySchema,
MemoSchema,
],
directory: dir.path,
);
・・・
tearDown(() async {
await isar.close(deleteFromDisk: true);
repository.dispose();
+ if (dir.existsSync()) {
+ await dir.delete(recursive: true);
+ }
});
- .dart_tool ディレクトリ配下のディレクトリパスを
Isar.open()
に与えています。 .dart_tool ディレクトリはデフォルトで gitignore されていて、flutter pub get
で作成されflutter clean
で削除される一時ファイルを置くのに最適なディレクトリです。テストで使用するディレクトリは .dart_tool 配下をつかうとよさそうです! - 毎回同じフォルダパスを返すと複数のテストが並列で実行するときに同じ DB ファイルを参照してエラーが起きるので、フォルダ名をランダムにしています。
-
setUp()
で作成したテスト用ディレクリをtearDown()
で削除します。
もちろんこれでもよいのですが、もっとエレガントな方法を次に紹介します!
path_provider
をモック実装する
対策案 2️⃣ mocktail を使って path_provider の MethodChannel をモック実装すると、テストでも getApplicationSupportDirectory()
が使えるようになります!
mocktail パッケージを追加する
dev_dependencies:
build_runner: ^2.1.11
flutter_lints: ^2.0.0
flutter_test:
sdk: flutter
isar_generator: ^3.0.0
+ mocktail: ^0.3.0
pedantic_mono: ^1.19.2
getApplicationSupportDirectory()
を実装する
mocktail を使って +// ignore_for_file: depend_on_referenced_packages
+
+import 'dart:io';
+import 'dart:math';
+
+import 'package:mocktail/mocktail.dart';
+import 'package:path/path.dart' as path;
+import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
+import 'package:plugin_platform_interface/plugin_platform_interface.dart';
・・・
setUp(() async {
+ PathProviderPlatform.instance = MockPathProviderPlatform();
final dir = await getApplicationSupportDirectory();
・・・
});
tearDown(() async {
await isar.close(deleteFromDisk: true);
repository.dispose();
+ final dir = await getApplicationSupportDirectory();
+ if (dir.existsSync()) {
+ await dir.delete(recursive: true);
+ }
});
・・・
+/// モック版のPathProviderPlatform
+class MockPathProviderPlatform extends Mock
+ with MockPlatformInterfaceMixin
+ implements PathProviderPlatform {
+ // 9桁のランダムな数字を生成する(例:355017887)
+ final name = Random().nextInt(pow(2, 32) as int);
+
+
+ Future<String> getApplicationSupportPath() async {
+ return Directory(
+ path.join(
+ Directory.current.path,
+ '.dart_tool',
+ 'test',
+ 'application_support_$name',
+ ),
+ ).path;
+ }
+}
-
PathProviderPlatform.instance
にPathProviderPlatform
インターフェースを実装したクラスのインスタンスを設定してあげることで簡単にモックに差し替える事ができます。 - とりあえず
getApplicationSupportDirectory()
のみをモック化すればよいので、getApplicationSupportPath()
を override します。
対策案 1️⃣ と 2️⃣ のどちらでもよいと思いますが、対策案 2️⃣ のほうがよりエレガントだし、他に path_provider
を使っている箇所があればまとめてモック化できるのでよさそうです。
というわけで対策案 2️⃣ を実施した上で進めていきます!
はまり所 2️⃣ ライブラリをダウンロードする必要がある
はまり所 1️⃣ を解消した上でテストを実行すると、今度は「libisar.dylib
を開こうとしてもファイルが見つからない」というエラーが出てきます。
Invalid argument(s): Failed to load dynamic library '/Users/susa/Develop/flutter-sample-isar/libisar.dylib': dlopen(/Users/susa/Develop/flutter-sample-isar/libisar.dylib, 0x0001): tried: '/Users/susa/Develop/flutter-sample-isar/libisar.dylib' (no such file)
dart:ffi new DynamicLibrary.open
_initializePath
initializeCoreBinary
openIsar
_IsarNative.open
Isar.open
main.<fn>
Isar Database の GitHub の README に解決方法が書かれていました。
README の和訳
ユニットテスト
単体テストまたは Dart コードで Isar を使用する場合は、テストで Isar を使用する前に
await Isar.initializeIsarCore(download: true)
呼び出してください。
Isar NoSQL データベースは、プラットフォームに適したバイナリを自動的にダウンロードします。libraries
で各プラットフォームのダウンロード場所を調整することもできます。
早速、書かれているとおりに修正してみます。
setUp(() async {
+ await Isar.initializeIsarCore(
+ download: true,
+ );
PathProviderPlatform.instance = MockPathProviderPlatform();
final dir = await getApplicationSupportDirectory();
・・・
テストを実行してみると、またエラーが出ました。。。テスト内で発生した HTTP リクエストがすべて遮断されてしまいました。テストの一貫性を担保するための仕組みですかね。でも HTTP リクエストを許可する方法があります!
IsarError: Could not download IsarCore library:
_downloadIsarCore
Warning: At least one test in this suite creates an HttpClient. When
running a test suite that uses TestWidgetsFlutterBinding, all HTTP
requests will return status code 400, and no network request will
actually be made. Any test expecting a real network connection and
status code will fail.
To test code that needs an HttpClient, provide your own HttpClient
implementation to the code under test, so that your test can
consistently provide a testable response to the code under test.
LateInitializationError: Local 'isar' has not been initialized.
dart:_internal LateError._throwLocalNotInitialized
main.<fn>
main.<fn>
テスト中の HTTP リクエストを許可するには、HttpOverrides.global = null
を書いてあげれば OK です。今回は一時的に HTTP リクエストを許可するように修正してみます!
setUp(() async {
+ final evacuation = HttpOverrides.current;
+ HttpOverrides.global = null;
await Isar.initializeIsarCore(
download: true,
);
+ HttpOverrides.global = evacuation;
PathProviderPlatform.instance = MockPathProviderPlatform();
final dir = await getApplicationSupportDirectory();
・・・
テストを実行してみると、、、今度は無事テストが成功しました 🎉
✓ MemoRepository カテゴリを検索できるはず
Exited
しかし!ルートディレクトリに libisar.dylib
というファイルが保存されています。
await Isar.initializeIsarCore(download: true)
を実行すると Isar がライブラリをダウンロードしてルートディレクトリに保存するようです。都度消せば済みますが誤ってコミットするリスクもあるので .dart_tool ディレクトリ配下にダウンロードするようにしてみましょう。
+import 'dart:ffi';
・・・
setUp(() async {
final evacuation = HttpOverrides.current;
HttpOverrides.global = null;
+ final isarLibraryDir = Directory(
+ path.join(
+ Directory.current.path,
+ '.dart_tool',
+ 'test',
+ 'isar_core_library',
+ Isar.version,
+ ),
+ );
+ if (!isarLibraryDir.existsSync()) {
+ await isarLibraryDir.create(recursive: true);
+ }
await Isar.initializeIsarCore(
+ libraries: <Abi, String>{
+ Abi.current(): path.join(
+ isarLibraryDir.path,
+ Abi.current().localName,
+ ),
+ },
download: true,
);
・・・
+/// Copy from 'package:isar/src/native/isar_core.dart';
+extension on Abi {
+ String get localName {
+ switch (Abi.current()) {
+ case Abi.androidArm:
+ case Abi.androidArm64:
+ case Abi.androidIA32:
+ case Abi.androidX64:
+ return 'libisar.so';
+ case Abi.macosArm64:
+ case Abi.macosX64:
+ return 'libisar.dylib';
+ case Abi.linuxX64:
+ return 'libisar.so';
+ case Abi.windowsArm64:
+ case Abi.windowsX64:
+ return 'isar.dll';
+ default:
+ // ignore: only_throw_errors
+ throw 'Unsupported processor architecture "${Abi.current()}".'
+ 'Please open an issue on GitHub to request it.';
+ }
+ }
+}
- ライブラリのバージョン毎にディレクトリを作成してそこにダウンロードします。
- テスト毎にライブラリをダウンロードするのは時間がかかりますので削除はしていません。
- テストを実行するプラットフォーム毎にライブラリのファイル名が異なります。定義は
package:isar/src/native/isar_core.dart
にありますが import しても使えなかったので、仕方ありませんがコピーしてきました。
もう一度テストを実行しましょう!
テストも成功して、.dart_tool 配下にライブラリがダウンロードされるようになりました 🎉
この時点のテストコードはこちらです。実質テストは 2 行なのですが、とても長くなりました。。。
テストコード
// ignore_for_file: depend_on_referenced_packages
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:flutter_sample_isar/collections/category.dart';
import 'package:flutter_sample_isar/collections/memo.dart';
import 'package:flutter_sample_isar/memo_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Isar isar;
late MemoRepository repository;
setUp(() async {
final evacuation = HttpOverrides.current;
HttpOverrides.global = null;
final isarLibraryDir = Directory(
path.join(
Directory.current.path,
'.dart_tool',
'test',
'isar_core_library',
Isar.version,
),
);
if (!isarLibraryDir.existsSync()) {
await isarLibraryDir.create(recursive: true);
}
await Isar.initializeIsarCore(
libraries: <Abi, String>{
Abi.current(): path.join(
isarLibraryDir.path,
Abi.current().localName,
),
},
download: true,
);
HttpOverrides.global = evacuation;
PathProviderPlatform.instance = MockPathProviderPlatform();
final dir = await getApplicationSupportDirectory();
isar = await Isar.open(
[
CategorySchema,
MemoSchema,
],
directory: dir.path,
);
// カテゴリの初期値を書き込む
await isar.writeTxn(() async {
await isar.clear();
await isar.categorys.putAll(
['仕事', 'プライベート', 'その他'].map((name) => Category()..name = name).toList(),
);
});
repository = MemoRepository(isar);
});
tearDown(() async {
await isar.close(deleteFromDisk: true);
repository.dispose();
final dir = await getApplicationSupportDirectory();
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
});
group('MemoRepository', () {
test('カテゴリを検索できるはず', () async {
final categories = await repository.findCategories();
expect(categories.length, 3);
});
});
}
/// Copy from 'package:isar/src/native/isar_core.dart';
extension on Abi {
String get localName {
switch (Abi.current()) {
case Abi.androidArm:
case Abi.androidArm64:
case Abi.androidIA32:
case Abi.androidX64:
return 'libisar.so';
case Abi.macosArm64:
case Abi.macosX64:
return 'libisar.dylib';
case Abi.linuxX64:
return 'libisar.so';
case Abi.windowsArm64:
case Abi.windowsX64:
return 'isar.dll';
default:
// ignore: only_throw_errors
throw 'Unsupported processor architecture "${Abi.current()}".'
'Please open an issue on GitHub to request it.';
}
}
}
/// モック版のPathProviderPlatform
class MockPathProviderPlatform extends Mock
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
// 9桁のランダムな数字を生成する(例:355017887)
final name = Random().nextInt(pow(2, 32) as int);
Future<String> getApplicationSupportPath() async {
return Directory(
path.join(
Directory.current.path,
'.dart_tool',
'test',
'application_support_$name',
),
).path;
}
}
はまり所 3️⃣ テストコードが長くなる
上記のコードをテストファイル毎に書いてたら保守できません、、ので一箇所にまとめてみます。
名前はなんでも良いのですが私はテストエージェントという名前のクラスを用意し、テストファイル毎に毎回行う処理を押し込めてしまいます。
ざっくり言うと、テストファイル毎の setUp()
と tearDown()
の処理をテストエージェントクラスに移譲しました。長くなるので折りたたんでおきます。
テストエージェントクラス
// ignore_for_file: depend_on_referenced_packages
import 'dart:ffi';
import 'dart:io';
import 'dart:math';
import 'package:flutter_sample_isar/collections/category.dart';
import 'package:flutter_sample_isar/collections/memo.dart';
import 'package:flutter_sample_isar/memo_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
/// テストエージェント
class TestAgent {
final _isarTestAgent = IsarTestAgent();
MemoRepository? _memoRepository;
/// 開始処理
Future<void> setUp() async {
TestWidgetsFlutterBinding.ensureInitialized();
// Isarテストエージェントの開始処理
await _isarTestAgent.setUp();
await _isarTestAgent.setUpDB();
}
/// 終了処理
Future<void> tearDown() async {
_memoRepository?.dispose();
_memoRepository = null;
await _isarTestAgent.tearDown();
}
/// メモリポジトリを返す
MemoRepository getMemoRepository() {
return _memoRepository ??= MemoRepository(
_isarTestAgent.isar,
);
}
}
/// Isar のテストエージェント
class IsarTestAgent {
Isar? _isar;
Isar get isar => _isar!;
/// 開始処理
Future<void> setUp() async {
// テスト時はインターネットが遮断されてしまうので一時的にインターネットに出られるようにする
final evacuation = HttpOverrides.current;
HttpOverrides.global = null;
// https://github.com/isar/isar#unit-tests によるとテスト時にはIsarのライブラリを
// ダウンロードする必要があるため、./dart_tool/ 配下にIsarコアバージョン毎にダウンロード
// 用のディレクトリを用意する。テスト毎にライブラリをダウンロードするのは時間がかかるので削除しない。
final isarLibraryDir = Directory(
path.join(
Directory.current.path,
'.dart_tool',
'test',
'isar_core_library',
Isar.version,
),
);
if (!isarLibraryDir.existsSync()) {
await isarLibraryDir.create(recursive: true);
}
// すでにダウンロード済みの場合はダウンロードをスキップするのでライブラリファイルの
// 存在チェックは不要
await Isar.initializeIsarCore(
libraries: <Abi, String>{
Abi.current(): path.join(
isarLibraryDir.path,
Abi.current().localName,
),
},
download: true,
);
HttpOverrides.global = evacuation;
PathProviderPlatform.instance = MockPathProviderPlatform();
// Isarインスタンスを作成する
final dir = await getApplicationSupportDirectory();
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
_isar = await Isar.open(
[
CategorySchema,
MemoSchema,
],
directory: dir.path,
);
}
/// 終了処理
Future<void> tearDown() async {
if (_isar?.isOpen == true) {
await _isar?.close(deleteFromDisk: true);
}
_isar = null;
final dir = await getApplicationSupportDirectory();
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
}
/// DBをセットアップする
Future<void> setUpDB() async {
// カテゴリの初期値を書き込む
return isar.writeTxn(() async {
await isar.categorys.putAll(
['仕事', 'プライベート', 'その他'].map((name) => Category()..name = name).toList(),
);
});
}
}
/// Copy from 'package:isar/src/native/isar_core.dart';
extension on Abi {
String get localName {
switch (Abi.current()) {
case Abi.androidArm:
case Abi.androidArm64:
case Abi.androidIA32:
case Abi.androidX64:
return 'libisar.so';
case Abi.macosArm64:
case Abi.macosX64:
return 'libisar.dylib';
case Abi.linuxX64:
return 'libisar.so';
case Abi.windowsArm64:
case Abi.windowsX64:
return 'isar.dll';
default:
// ignore: only_throw_errors
throw 'Unsupported processor architecture "${Abi.current()}".'
'Please open an issue on GitHub to request it.';
}
}
}
/// モック版のPathProviderPlatform
class MockPathProviderPlatform extends Mock
with MockPlatformInterfaceMixin
implements PathProviderPlatform {
// 9桁のランダムな数字を生成する(例:355017887)
final name = Random().nextInt(pow(2, 32) as int);
Future<String> getApplicationSupportPath() async {
return Directory(
path.join(
Directory.current.path,
'.dart_tool',
'test',
'application_support_$name',
),
).path;
}
}
すると、テストファイルの全文は次の通りスッキリしました!これならテストファイルが増えても大丈夫ですね!
import 'package:flutter_test/flutter_test.dart';
import '../test_utils/test_agent.dart';
void main() {
final agent = TestAgent();
setUp(agent.setUp);
tearDown(agent.tearDown);
group('MemoRepository', () {
test('カテゴリを検索できるはず', () async {
final repository = agent.getMemoRepository();
final categories = await repository.findCategories();
expect(categories.length, 3);
});
});
}
やっとこれで単体テストが出来るようになりました。次は Widget テストをやってみます!
はまり所 4️⃣ Widget テストは非同期処理が進まない
メモ一覧画面に対して、「初期表示時はメモは0件のはず」という Widget テストをやってみます。さっと書いてみると次のようになります。
import 'package:flutter/material.dart';
import 'package:flutter_sample_isar/memo_index_page.dart';
import 'package:flutter_test/flutter_test.dart';
import 'test_utils/test_agent.dart';
void main() {
final agent = TestAgent();
late Widget testApp;
setUp(() async {
await agent.setUp();
testApp = MaterialApp(
home: MemoIndexPage(
memoRepository: agent.getMemoRepository(),
),
);
});
tearDown(agent.tearDown);
group('MemoIndexPage', () {
testWidgets('初期表示時はメモは0件のはず', (tester) async {
await tester.pumpWidget(testApp);
// ListView がいるはず
expect(find.byType(ListView), findsOneWidget);
// メモは0件のはず
final state =
tester.state(find.byType(MemoIndexPage)) as MemoIndexPageState;
expect(state.memos.length, 0);
});
});
}
-
await tester.pumpWidget(testApp);
でテストアプリを起動します。 - 起動後
expect()
で 2 つテストを行っています。
これを実行すると、、、、ずっと実行しっぱなしになって一向にテストが終わらない現象が起きます。
Widget テストが終わらない
原因は、Isar Database の非同期のトランザクション処理が予約されたまま実行されず、Isar のクローズ処理内で未実行のトランザクション処理を待ち続けてしまっているためです。どうやら、Widget テストは非同期処理が進まないようになっているようです。テストの高速化のためにそのようになっていると推測しています。ちなみに、単体テストは非同期処理が進みます。
試しに次のような単純化したテストをやってみると、print(2);
がいつまで経っても実行されませんでした。
Widget テストが終わらないサンプル
解決方法は、非同期処理を前に進ませたい箇所を await tester.runAsync(() async {});
で括ってあげることです。
Widget テストが終わるサンプル
今回のテストも次のように修正すればうまくいきます!
testWidgets('初期表示時はメモは0件のはず', (tester) async {
+ await tester.runAsync(() async {
await tester.pumpWidget(testApp);
+ });
// ListView がいるはず
expect(find.byType(ListView), findsOneWidget);
これで単体テストと Widget テストが進められる土台が出来ました!あとはガリガリテストコードを書いていくだけです!
完成したテストコードは公開しています
完成した(カバレッジ率 100% の)メモアプリのテストコードは下記で公開しています!下記コードでは本記事で紹介していない、AlertDialog
、DropdownButton
、TextField
を使ったテスト、GitHub Actions を使った自動テストワークフロー、Codecov を使ったカバレッジ測定があったりします。参考になれば幸いです!
最後に
Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!ぜひ Flutter 界隈を盛り上げていきましょう!
あわせて読みたい
Discussion
とても助かりました、ありがとうございます!
1点補足です。
「はまり所 3️⃣ テストコードが長くなる」に記載の
「// Isarインスタンスを作成する」の部分で
が抜けているようです。
GitHubのコードを確認して追加したらクラッシュが解消したので記載しておきます。
ご指摘ありがとうございました!修正しました!