Edited at

【Flutter】時間・時刻絡みのコードのテスト方法

結構迷ったので記録しておきます。

一般的に、時間や時刻が絡んだコードをテストするには、「時間や時刻を外からいじれる」ように設計しておいて

本番環境では実際の時間や時刻を渡し、テスト環境ではテスト用に作った時間や時刻を渡すようにするのが良いそうですが、

Flutterでは時間や時刻を外から渡せる設計にしなくてもテストできる方法が用意されてますので紹介します。


Unit Test

まずはユニットテストからいきます。

これはFlutterというよりはDartにおけるテストの話になりますね。


タイマー仕掛けでもやしが腐る

時刻が関係せず、時間経過だけに意味がある例をあげます。

今回テストしたいクラスはこちら。


moyashi.dart

import 'dart:async';

class Moyashi {
Moyashi() {
Timer(Duration(seconds: 10), () {
isFresh = false;
});
}
bool isFresh = true;
}


もやしです。もやしは腐るのが早いです。

私は自分で作ったもやし炒めを数日間放置したものを「まだいけるだろ〜〜」で食べ、そのまま激混みの文化祭に出かけた所

見事に嘔吐して喉を痛め、そのままインフルエンザに感染したことがあります。

このMoyashiクラスはisFreshプロパティを持ち、最初はtrueですが、インスタンスが作られて10秒後にfalseになります。つまり腐ります。早いね。


テストコード

https://pub.dev/packages/fake_async

こちらのFakeAsyncパッケージを使用します。

pubspec.yamlに追加するのですが、

dependenciesではなくdev_dependenciesの方に追加すればOKです。

開発時にしか使いませんからね。


pubspec.yaml

dev_dependencies:

test: any
fake_async: ^1.0.1


unit_test.dart

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/awaitasyncと同じなのが嫌で変えました。クラス名に合わせてfakeAsyncもアリかと思いましたが、このパッケージではfakeAsyncは関数名なのでこれも却下。


ドラえもんの誕生日までの時間が知りたい

続いて日付や時刻に意味がある場合の例です。

私は昔から生存目標日を設定しております。それは、ドラえもんの誕生日である「2112年9月3日」です。まあ、いけるやろ??

こんな感じのクラスを用意して、あと何日生きればいいか聞いてみることにしましょう。

(良くない例)


duration_until_doraemon.dart

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でオーバーライドする

ということで、こちらのパッケージを使います。

https://pub.dev/packages/clock

こちらはプロダクトコード本体に組み込む必要がありますので、pubspec.yamldependenciesの方に追加しておきます。


pubspec.yaml

dependencies:

clock: ^1.0.1

本体コードはこう書き換えます


duration_until_doraemon.dart

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がオーバーライドされて、仮想的な時刻を表すようになります。

テストコードは次のようになります。


unit_test.dart

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秒でもやしが腐るアプリです。

言い換えると、時刻が関係なく、時間経過に意味がある、ということです。

コード全文はこちら。


main.dart

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に、時間経過をシミュレートする機能があります。それを使いましょう。


widget_test.dart

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する際にMyAppMaterialAppで包んでいるのは、Widgetを表示するために必要な情報をMaterialAppが提供しているからです。

もし他にも必要なものがあればウィジェットツリーの先祖から提供しておきます。ThemeとかProviderとかね。


ドラえもんの誕生日まで秒単位でカウントダウンしてくれるアプリ

時刻に意味があるケース、ということです。

このケースでは、fakeAsyncclockが必要になります。

pumpWidgetinitialTimeを渡して仮想時刻を指定できればいいんですけど、そんな機能はないです。残念!

とりあえず作ってみるとこんな感じ。


main.dart

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


periodicTimerをきちんとdisposeするコードも書いておきました。

これがないと、このWidgetが無効化されたあともタイマーが動き続けておかしなことになります。


うまくいかないテストコード

テストコードは、例えばこんな感じに書けそうな気がします。

(良くない例)


widget_test.dart

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を使わない

私が発見できた解決策はこちらです。もっといい方法があるかもしれません。


widget_test.dart

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.pumpWidgettester.pumpawaitをつけないと、「awaitをつけなさいよ〜」みたいなエラーが出ますが

awaitを付ける代わりに、FakeAsync.flushMicrotasksメソッドを使ってマイクロタスクキューの中身を明示的に消化してしまうことができます。

非同期処理を同期的にここで処理してしまうわけですね。

これで通ります!やったね!!


Widgetとモデルの時間管理を分離する

コメントにて、


  • Widgetの時間管理はあくまでWidgetTesterのpumpメソッドを使いつつ

  • 時刻指定が必要なモデルの部分だけfakeAsyncで管理する

という手法を教えていただきました!詳しくはコメント欄をご覧ください。


Integration Test

Flutterのテストは大きく分けて3種類ありますが、これがその3種類目、Integration Test です。

Integration Testで時間経過・時刻指定をシミュレートする方法についての記述は検索しても全然見つからなかったので

自力で考えました。

なので、もっといい方法があるかもしれません。


そもそも必要?

Integration Testは「実際に端末(またはシミュレーター)上でアプリを動かしてみてどうか」というテストなので、

「そこで時間をシミュレートしたらIntegration Testの意味がなくない?」

という気もします。

「無理やりにでもfakeAsyncをIntegration Testで使うとしたらこういう手がある」という感覚でお読みください。


もやしが腐るアプリ

まずは時間経過のシミュレートです。先程のもやしが腐るアプリをそのまま使います。

あとでウィジェット内のTextを調べたいので、該当Widgetにkeyを持たせおきます。


main.dart

...

@override
Widget build(BuildContext context) {
return Text(_isFresh ? '新鮮なもやし!!' : '腐ったもやし…', key: const Key('main_text'));
}

公式情報に乗っ取ると、Integration Testでは、test_driverというディレクトリを作って、その中にapp.dartapp_test.dartを用意します。

flutter_driverパッケージを導入します。


pubspec.yaml

dev_dependencies:

flutter_driver:
sdk: flutter


app.dart

app.dartの仕事は主に


  • enableFlutterDriverExtensionを呼ぶこと

  • 対象となるアプリを起動すること

です。

「アプリの起動」というのは、lib/main.dartmainを呼んでもいいし、runApp()に好きなWidgetを与えて実行してもOKです。

enableFlutterDriverExtensionには、文字列を受け取って文字列を返すhandler関数を登録しておきます。

すると、あとでテストコードからこのhandler関数にアクセスすることができます。

そして今回は、これらの「仕事」をすべてfakeAsync環境の中で行うことにします。

そして、時間を進める関数をenableFlutterDriverExtensionに登録しておいて、テストコードから呼び出すことにします。

ということでこんな感じになります。


app.dart

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

テストコードはこちら。


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だけ持たせておきましょう。


main.dart

...

@override
Widget build(BuildContext context) {
return Text(_duration.inSeconds.toString(), key: const Key('main_text'));
}

app.dartはもやしが腐るアプリとほぼ同じですが、fakeAsyncにinitialTimeを与えておきます


app.dart

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


テストコード本体はこんな感じ


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('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年以上生きましょう。