結構迷ったので記録しておきます。
一般的に、時間や時刻が絡んだコードをテストするには、「時間や時刻を外からいじれる」ように設計しておいて
本番環境では実際の時間や時刻を渡し、テスト環境ではテスト用に作った時間や時刻を渡すようにするのが良いそうですが、
Flutterでは時間や時刻を外から渡せる設計にしなくてもテストできる方法が用意されてますので紹介します。
Unit Test
まずはユニットテストからいきます。
これはFlutterというよりはDartにおけるテストの話になりますね。
タイマー仕掛けでもやしが腐る
時刻が関係せず、時間経過だけに意味がある例をあげます。
今回テストしたいクラスはこちら。
import 'dart:async';
class Moyashi {
Moyashi() {
Timer(Duration(seconds: 10), () {
isFresh = false;
});
}
bool isFresh = true;
}
もやしです。もやしは腐るのが早いです。
私は自分で作ったもやし炒めを数日間放置したものを「まだいけるだろ〜〜」で食べ、そのまま激混みの文化祭に出かけた所
見事に嘔吐して喉を痛め、そのままインフルエンザに感染したことがあります。
このMoyashi
クラスはisFresh
プロパティを持ち、最初はtrue
ですが、インスタンスが作られて10秒後にfalse
になります。つまり腐ります。早いね。
テストコード
こちらのFakeAsyncパッケージを使用します。
pubspec.yaml
に追加するのですが、
dependencies
ではなく**dev_dependencies
の方に追加すればOK**です。
開発時にしか使いませんからね。
dev_dependencies:
test: any
fake_async: ^1.0.1
import 'package:fake_async/fake_async.dart';
import 'package:stepbystep/moyashi.dart';
import 'package:test/test.dart';
void main() {
test('Moyashi goes bad in 10 seconds', () {
// テストケースの中身全体をfakeAsync環境で包む
fakeAsync((FakeAsync fakeClock) {
final Moyashi _moyashi = Moyashi();
expect(_moyashi.isFresh, isTrue);
// fakeAsync環境の時間を11秒進める
fakeClock.elapse(Duration(seconds: 11));
expect(_moyashi.isFresh, isFalse);
});
});
}
このテストを実行するとちゃんと通ります。やったね!
fakeClock.elapse(時間);
とするだけで、タイマー処理もきちんと発火して、中身が実行されています。
import
のところにあるstepbystep
というのは私が練習台としていつも使っているプロジェクトの名前です。あまり気にしないで…。
fakeClock
の部分は、パッケージのサンプルではasync
という名前が使われていますが、async/await
のasync
と同じなのが嫌で変えました。クラス名に合わせてfakeAsync
もアリかと思いましたが、このパッケージではfakeAsync
は関数名なのでこれも却下。
ドラえもんの誕生日までの時間が知りたい
続いて日付や時刻に意味がある場合の例です。
私は昔から生存目標日を設定しております。それは、ドラえもんの誕生日である「2112年9月3日」です。まあ、いけるやろ??
こんな感じのクラスを用意して、あと何日生きればいいか聞いてみることにしましょう。
(良くない例)
class DurationUntilDoraemon {
final DateTime _birthDay = DateTime(2112, 9, 3);
Duration get duration => _birthDay.difference(DateTime.now());
}
このクラスは、内部でDateTime.now()
を使っているため、テストができません!
テストでは、「今日が2000年9月3日だとしたら、112年0ヶ月0日という値が返ってくる」ということを確認したいわけです。
ということはテスト環境内を「2000年9月3日」という仮想的な時刻にしたいわけですが、残念ながらDateTime.now()
は常に実際の時刻を返してしまいます。
clockを使っておいて、fakeAsyncでオーバーライドする
ということで、こちらのパッケージを使います。
こちらはプロダクトコード本体に組み込む必要がありますので、pubspec.yaml
のdependencies
の方に追加しておきます。
dependencies:
clock: ^1.0.1
本体コードはこう書き換えます
import 'package:clock/clock.dart';
class DurationUntilDoraemon {
final DateTime _birthDay = DateTime(2112, 9, 3);
Duration get duration => _birthDay.difference(clock.now());
}
先程のDateTime.now()
がclock.now()
に書き換わっています。
Clockパッケージをimportすると、clock
というグローバル変数が自動的に使えるようになっていますので、それを使います。
実はFakeAsyncパッケージとClockパッケージは連携していて、グローバル変数clock
をfakeAsync環境の中にいれると、clock
がオーバーライドされて、仮想的な時刻を表すようになります。
テストコードは次のようになります。
import 'package:fake_async/fake_async.dart';
import 'package:stepbystep/duration_until_doraemon.dart';
import 'package:test/test.dart';
void main() {
test('correct duration until doraemon', () {
fakeAsync((FakeAsync fakeClock) {
final DurationUntilDoraemon _durationUntilDoraemon =
DurationUntilDoraemon();
expect(_durationUntilDoraemon.duration, equals(Duration(days: 40907)));
}, initialTime: DateTime(2000, 9, 3));
});
}
fakeAsync
関数はinitialTime
という引数をとり、ここで仮想時刻を設定することができます。
Duration(days:40907)
という日数は、あらかじめ計算しておいた「正解」です。
このテストは実際に通過します。やったね!
Widget Test
続いてウィジェットテストです。こちらは完全にFlutterの話です。
タイマー仕掛けでもやしが腐るアプリ
まずは10秒でもやしが腐るアプリです。
言い換えると、時刻が関係なく、時間経過に意味がある、ということです。
(ただし、時間経過を、タイマーではなく、記録した時刻と現在時刻の差を計算して判定している場合は、「時刻に意味があるケース」として考えてください。)
コード全文はこちら。
import 'dart:async';
import 'package:flutter/material.dart';
void main() =>
runApp(MaterialApp(home: Scaffold(appBar: AppBar(), body: MyApp())));
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool _isFresh = true;
@override
void initState() {
super.initState();
Timer(Duration(seconds: 10), () {
setState(() {
_isFresh = false;
});
});
}
@override
Widget build(BuildContext context) {
return Text(_isFresh ? '新鮮なもやし!!' : '腐ったもやし…');
}
}
始めは「新鮮なもやし!!」と表示されていますが、10秒経過すると「腐ったもやし…」に変わります。それだけのアプリ。
上記コードのMyApp
ウィジェットをテストすることにしましょう。
実は、Widgetテストで時間経過をさせたい場合は、fakeAsyncは必要ありません!
テストコードで用いるtester.pump
に、時間経過をシミュレートする機能があります。それを使いましょう。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stepbystep/main.dart';
void main() {
testWidgets('the moyashi text goes bad in 10 seconds',
(WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: MyApp()));
expect(find.text('新鮮なもやし!!'), findsOneWidget);
await tester.pump(Duration(seconds: 11));// 11秒経過
expect(find.text('腐ったもやし…'), findsOneWidget);
});
}
これで通ります。やったね!!
pumpWidget
する際にMyApp
をMaterialApp
で包んでいるのは、Widgetを表示するために必要な情報をMaterialApp
が提供しているからです。
もし他にも必要なものがあればウィジェットツリーの先祖から提供しておきます。ThemeとかProviderとかね。
ドラえもんの誕生日まで秒単位でカウントダウンしてくれるアプリ
時刻に意味があるケース、ということです。
このケースでは、fakeAsync
とclock
が必要になります。
pumpWidget
でinitialTime
を渡して仮想時刻を指定できればいいんですけど、そんな機能はないです。残念!
とりあえず作ってみるとこんな感じ。
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
void main() =>
runApp(MaterialApp(home: Scaffold(appBar: AppBar(), body: MyApp())));
class MyApp extends StatefulWidget {
final DateTime _birthDay = DateTime(2112, 9, 3);
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
Duration _duration;
Timer _timer;
@override
void initState() {
super.initState();
_duration = widget._birthDay.difference(clock.now());
// 毎秒、現在時刻とドラえもんの誕生日の差を計算して画面更新
_timer = Timer.periodic(Duration(seconds: 1), (_) {
setState(() {
_duration = widget._birthDay.difference(clock.now());
});
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(_duration.inSeconds.toString());
}
}
periodic
なTimer
をきちんとdispose
するコードも書いておきました。
これがないと、このWidgetが無効化されたあともタイマーが動き続けておかしなことになります。
うまくいかないテストコード
テストコードは、例えばこんな感じに書けそうな気がします。
(良くない例)
import 'package:fake_async/fake_async.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stepbystep/main.dart';
void main() {
testWidgets('countdown correctly', (WidgetTester tester) async {
fakeAsync((FakeAsync fakeClock) async {
await tester.pumpWidget(MaterialApp(home: MyApp()));
expect(find.text('3534364800'), findsOneWidget);
fakeClock.elapse(Duration(seconds: 1));
await tester.pump();
expect(find.text('3534364799'), findsOneWidget);
}, initialTime: DateTime(2000, 9, 3));
});
}
3534364800という数字は、あらかじめ計算しておいた「正解」の秒数です。
35億3436万4800秒。そのくらい楽勝で生きられそうですね。
1秒後には1減って、3534364799になっているはずです。
このテストを実行すると、次のエラーが出ます。
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown running a test:
Asynchronous call to guarded function leaked.
You must use "await" with all Future-returning test APIs.
The guarded method "pumpWidget" from class WidgetTester was called from
file:///Users/●●●●●●/stepbystep/test/widget_test.dart on line 17,
but never completed before its parent scope closed.
The guarded method "pump" from class AutomatedTestWidgetsFlutterBinding was called from
package:flutter_test/src/widget_tester.dart on line 318, but never completed before its parent scope
closed.
こちら、実はエラーが発生している行番号が若干ズレているのですが、掲載したコードは必要な所だけ抜粋したものなのでそのせいです。
「fakeAsync
関数の返り値がFuture
なのにawait
がついてないのがダメなのか?」
と考えてfakeAsync
の頭にawait
を付けると、
今度はawait tester.pumpWidget
が永遠に終わらなくなります。
困りましたね。
解決策:awaitを使わない
私が発見できた解決策はこちらです。もっといい方法があるかもしれません。
import 'package:fake_async/fake_async.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stepbystep/main.dart';
testWidgets('countdown correctly', (WidgetTester tester) async {
fakeAsync((FakeAsync fakeClock) {
tester.pumpWidget(MaterialApp(home: MyApp()));
fakeClock.flushMicrotasks();
expect(find.text('3534364800'), findsOneWidget);
fakeClock.elapse(Duration(milliseconds: 1010));
tester.pump();
fakeClock.flushMicrotasks();
expect(find.text('3534364799'), findsOneWidget);
}, initialTime: DateTime(2000, 9, 3));
});
}
tester.pumpWidget
やtester.pump
にawait
をつけないと、「await
をつけなさいよ〜」みたいなエラーが出ますが
await
を付ける代わりに、FakeAsync.flushMicrotasks
メソッドを使ってマイクロタスクキューの中身を明示的に消化してしまうことができます。
非同期処理を同期的にここで処理してしまうわけですね。
これで通ります!やったね!!
Widgetとモデルの時間管理を分離する
コメントにて、
- Widgetの時間管理はあくまでWidgetTesterのpumpメソッドを使いつつ
- 時刻指定が必要なモデルの部分だけfakeAsyncで管理する
という手法を教えていただきました!詳しくはコメント欄をご覧ください。
Integration TestでfakeAsync(非推奨)
Flutterのテストは大きく分けて3種類ありますが、これがその3種類目、Integration Test です。
Integration Testで時間経過・時刻指定をシミュレートする方法についての記述は検索しても全然見つからなかったので
自力で考えました。
*(※記事の公開から数日後に追記)*ここで紹介する方法は、一応下記のコードは動きますが、ちょっと複雑なことをやろうとするとたちまち上手くいかなくなりましたので非推奨とします。
そもそも必要?
Integration Testは「実際に端末(またはシミュレーター)上でアプリを動かしてみてどうか」というテストなので、
「そこで時間をシミュレートしたらIntegration Testの意味がなくない?」
という気もします。
「無理やりにでもfakeAsyncをIntegration Testで使うとしたらこういう手がある」という感覚でお読みください。
もやしが腐るアプリ
まずは時間経過のシミュレートです。先程のもやしが腐るアプリをそのまま使います。
あとでウィジェット内のTextを調べたいので、該当Widgetにkeyを持たせおきます。
...
@override
Widget build(BuildContext context) {
return Text(_isFresh ? '新鮮なもやし!!' : '腐ったもやし…', key: const Key('main_text'));
}
公式情報に乗っ取ると、Integration Testでは、test_driver
というディレクトリを作って、その中にapp.dart
とapp_test.dart
を用意します。
flutter_driverパッケージを導入します。
dev_dependencies:
flutter_driver:
sdk: flutter
app.dart
app.dart
の仕事は主に
-
enableFlutterDriverExtension
を呼ぶこと - 対象となるアプリを起動すること
です。
「アプリの起動」というのは、lib/main.dart
のmain
を呼んでもいいし、runApp()
に好きなWidgetを与えて実行してもOKです。
enableFlutterDriverExtension
には、文字列を受け取って文字列を返すhandler
関数を登録しておきます。
すると、あとでテストコードからこのhandler
関数にアクセスすることができます。
そして今回は、これらの「仕事」をすべて**fakeAsync
環境の中で行う**ことにします。
そして、時間を進める関数をenableFlutterDriverExtension
に登録しておいて、テストコードから呼び出すことにします。
ということでこんな感じになります。
import 'package:fake_async/fake_async.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:stepbystep/main.dart' as app;
void main() {
fakeAsync((FakeAsync fakeClock) {
enableFlutterDriverExtension(handler: (String action) {
if (action == 'elapse') {
fakeClock.elapse(Duration(seconds: 20));
}
return null;
});
app.main();
});
}
elapse
という文字列を与えてhandler
を呼び出すと、時間を20秒進めます。(シミュレーション内の時間なので実際に20秒待つわけではありません)
app_test.dart
テストコードはこちら。
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
FlutterDriver driver;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
if (driver != null) {
await driver.close();
}
});
test('the moyashi text goes bad in 10 seconds', () async {
expect(await driver.getText(find.byValueKey('main_text')), '新鮮なもやし!!');
await driver.requestData('elapse');
// 本当に0.1秒待つ
await Future<void>.delayed(Duration(milliseconds: 100));
expect(await driver.getText(find.byValueKey('main_text')), '腐ったもやし…');
});
}
テストコード内の
await Future<void>.delayed(Duration(milliseconds:100));
の行では、実時間で0.1秒待機しています。(シミュレーション内の時間ではない!)
これをしないと、画面の文字が更新されていないようで、テストが通りません。
app.dart
の方でfakeClock.flushMicrotasks()
を実行してみることも試しましたが、意味がなかったです。
さて、一応これでテストは通りますが、気に入らない点がいくつかありますね…
- 実時間で0.1秒待つのがやはりイマイチ
-
driver.requestData
というデータをリクエストするAPI使って時間経過の指示を出してるのがイマイチ - そもそも論で全然違う良い方法があるかも
このあたり、知識を共有していただけると嬉しいです!
ドラえもんカウントダウン
時刻に意味があるケースも見ていきましょう。
ドラえもんカウントダウンのアプリをそのまま使いますが、keyだけ持たせておきましょう。
...
@override
Widget build(BuildContext context) {
return Text(_duration.inSeconds.toString(), key: const Key('main_text'));
}
app.dart
はもやしが腐るアプリとほぼ同じですが、fakeAsyncにinitialTimeを与えておきます
import 'package:fake_async/fake_async.dart';
import 'package:flutter_driver/driver_extension.dart';
import 'package:stepbystep/main.dart' as app;
void main() {
fakeAsync((FakeAsync fakeClock) {
enableFlutterDriverExtension(handler: (String action) {
if (action == 'elapse') {
fakeClock.elapse(Duration(seconds: 20));
}
return null;
});
app.main();
}, initialTime: DateTime(2000, 9, 3));
}
テストコード本体はこんな感じ
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
FlutterDriver driver;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
if (driver != null) {
await driver.close();
}
});
test('countdown correctly', () async {
expect(await driver.getText(find.byValueKey('main_text')), '3534364800');
await driver.requestData('elapse');
// 本当に0.1秒待つ
await Future<void>.delayed(Duration(milliseconds: 100));
expect(await driver.getText(find.byValueKey('main_text')), '3534364780');
});
}
やってることはもやしが腐るアプリとほとんど同じですね。
これで通りますが、なんとなくイマイチなのも同じ。アイデアあったらください🙏
実時間とシミュレーション内時間の分離
今回のIntegration Testのようにアプリの実行をfakeAsync
内にいれてしまうと、実時間とシミュレーション内時間が分離されます。
なので、fakeAsync.elapseを呼ばない限りアプリ内の時間は進みません。
0.1秒待つ行を
Future<void>.delayed(Duration(seconds:10));
などとしても、10秒待つ間(そのあとも)カウントダウンは止まったままです。テストの上では便利ですね。
おわり
ということで今回は以上です。みなさんももやしを腐らせないように気をつけながら、あと93年以上生きましょう。