Flutterをプロダクトで利用するには単体テストの書きやすさも重要だと思います。
この点、Dartは単体テストが書きやすいです。
特にJavaでMockitoを使っていた人は、Dartにもmockitoがあり、ほぼ学習コストがかかりません。
1年間、Flutter開発で単体テストを書いてきたので、ハマりポイントはほぼ網羅したつもりです。
足りない部分があったら完全版になるように都度更新していきます。
基本
まずは、テストできるようにします。
dev_dependencies:
mockito: ^4.0.0
flutter_test:
sdk: flutter
以上です。
暗黙的インターフェイス
DartではJavaの文法でのinterface
はありません。代わりにabstract
クラスで代用することになりますが、わざわざテストのためにabstract
クラスを ___作る必要はない___です。
DartではImplicit interface(暗黙的インターフェイス)が言語レベルで組み込まれています。
つまり、全てのクラスに対して、implements
するとそのインターフェイスを実装することになります。
class Person {
final _name;
Person(this._name);
// publicなこのメソッドがinterface
String greet(String who) => 'Hello, $who. I am $_name.';
}
class Impostor implements Person {
・・・
// これを実装しないとエラー
String greet(String who) {
・・・
}
}
たまにJavaや他の言語でもレイヤーにinterfaceを持ちたがる人がいますが、実装クラスを単体テストのライブラリでMockにできる場合は、(依存の方向を逆にしたい場合を除いて)ほぼ不要なので、Dartが特別優れている訳ではない気がします。が、言語レベルであることで余計なインターフェイスを作る発想がなくなるのはとても良いことだと思います。
次から、レイヤーごとの単体テストの例を書いていきます。
HttpClientのテスト
ほとんどのアプリではHttp通信をすると思うので先に書いておきます。
mock_web_serverを使うのが簡単です。
Androidで利用されることが多いMockWebServerとほぼ同じです。
ただし、Dart2に対応していなくPull Reqeustが出ていますが、マージされていません。
ひとまずは、フォークしているものを使いましょう。
マージされました。
dev_dependencies:
mock_web_server: ^4.0.0
マージされたので以下の設定は不要です。
dependency_overrides:
mock_web_server:
git:
url: https://gitlab.com/haarts/MockWebServer.git
余談ですが、このように本家で対応されていない場合でも dependency_overrides
を使うことでオーバーライドをすることができます。
本家で対応された場合は、dependency_overrides
を削除するだけです。
dependency_overrides
している場合は実行時に赤字でコンソールで表示されるので、オーバーライドしていることを忘れることがありません。
次にテストの例を挙げます。
HogeHttpClientというテスト対象があったとします。
class HogeHttpClient {
final String baseUrl;
static HogeHttpClient _instance;
factory HogeHttpClient({String baseUrl = 'https://hoge.com'}) {
if (_instance == null) {
_instance = new HogeHttpClient._internal(baseUrl);
}
return _instance;
}
HogeHttpClient._internal(this.baseUrl);
Future<HogeEntity> fetch() async {
final response = await http.get('$baseUrl/hoge');
final responseJson = json.decode(response.body);
return new HogeEntity.fromJson(responseJson);
}
}
class HogeEntity {
final String fuga;
const HogeEntity({
this.fuga,
});
factory HogeEntity.fromJson(Map<String, dynamic> json) {
return HogeEntity(fuga: json['fuga']);
}
}
それのテストケースは以下です。
void main() {
var _server = new MockWebServer(port: 8081);
setUp(() async {
await _server.start();
});
tearDown(() {
_server.shutdown();
});
test("fetchForcingUpdate()", () async {
_server.enqueue(body: '''
{
"fuga" : "abcdefg"
}
''');
var target = HogeHttpClient(baseUrl: "http://127.0.0.1:8081");
var actual = await target.fetch();
expect(actual.fuga.toString(), "abcdefg");
var request = _server.takeRequest();
expect(request.uri.path, "/hoge");
});
}
説明するまでもなく簡単ですね。
urlをmockwebserver用に渡して、start()
して、 MockWebServer
にレスポンス情報を渡すだけです。
もっと細かい情報を渡したい場合は、enqueueResponse
メソッドを使でばいいでしょう。
_server.enqueueResponse(MockResponse()
..httpCode = 201
..body = "Created"
..headers = {"hogehoge": "1234"}
..delay = new Duration(seconds: 2));
requestを検証したい場合に、takeRequest()
を呼び出して利用できます。
気をつけることは、外部からbaseUrlを渡せるようにHttpClientクラスを設計しておくことぐらいです。
Repositoryなどのテスト
HogeRepositoryというhttpclientを呼び出すだけのクラスがあったとします。
class HogeRepository {
final HogeHttpClient _client;
HogeRepository(this._client);
Future<HogeEntity> fetch() {
return _client.fetch();
}
}
そのテストケースは以下です。
Repository以外でもほとんどのクラスはこんな感じでテストしていくはずです。
class MockHogeHttpClient extends Mock implements HogeHttpClient {}
main() {
group('fetch()', () {
var client = new MockHogeHttpClient();
test('return null.', () async {
when(client.fetch()).thenAnswer((_) => Future.value(null));
var target = new HogeRepository(client);
var actual = await target.fetch();
expect(actual, isNull);
});
test('return entity', () async {
var entity = HogeEntity(fuga: "fuga");
when(client.fetch()).thenAnswer((_) => Future.value(entity));
var target = new HogeRepository(client);
var actual = await target.fetch();
expect(actual.fuga, "fuga");
});
});
}
一行目で、HogeHttpClientをmockitoでmock化しています。
その際に暗黙的インターフェイスでモック対象を実装して、mockitoのMockクラスを継承します。
client.fetch()
の戻り値はFuture(=Promiseのこと)です。
その場合の戻り値の宣言は、thenReturn
ではなくて、thenAnswer
を使います。
Futureのインスタンスは、ここでは、Future.value()
を使っていますが、SynchronousFuture
を使っても同じです。
ただし、SynchronousFuture
を使うと catchError()
がある場合に動作が止まるので、Future.value()
で返すのが無難です。
Reduxのテスト
Reduxを使わなければ関係ないですが、おそらくflutterではReduxで実装することが多いと思われるので書いときます。
Middlewareのテスト
テスト対象の関数です。
List<Middleware<AppState>> hogeMiddleware(
HogeRepository repository,
) {
return [
TypedMiddleware<AppState, HogeAction>(_fetchHoge(repository)),
];
}
void Function(
Store<AppState> store,
HogeAction action,
NextDispatcher next,
) _fetchHoge(
HogeRepository repository,
) {
return (store, action, next) {
next(action);
next(LoadingAction());
repository.fetch().then((HogeEntity entity) {
store.dispatch(HogeSucceededAction(entity));
}).catchError((error) {
print(error);
}).whenComplete(() {
next(LoadCompleteAction());
});
};
}
テストケースは以下です。最初にstoreのインスタンスで必要なものを設定して、dispatchします。
class MockMiddleware extends Mock implements MiddlewareClass<AppState> {}
class MockHogeRepository extends Mock implements HogeRepository {}
main() {
group('HogeMiddleware', () {
final repository = MockHogeRepository();
final captor = MockMiddleware();
test('should fetch hoge', () async {
final store = Store<AppState>(
appReducer,
initialState: new AppState.loading(),
middleware: hogeMiddleware(repository)..add(captor),
);
var entity = HogeEntity(
fuga: "fuga",
);
when(repository.fetch()).thenAnswer((_) => Future.value(entity));
// テスト開始
store.dispatch(HogeAction());
verify(repository.fetch());
verify(captor.call(store, predicate((action) {
return (action is HogeAction);
}), any));
verify(captor.call(store, predicate((action) {
return (action is LoadingAction);
}), any));
// ここでFutureが終わるのを待つ。
await untilCalled(store.dispatch(any));
verify(captor.call(store, predicate((action) {
if (action is HogeSucceededAction) {
return action.entity.fuga == 'fuga';
}
return false;
}), any));
verify(captor.call(store, predicate((action) {
return (action is LoadCompleteAction);
}), any));
});
reset(repository);
reset(captor);
});
}
ポイントは、await untilCalled
の部分です。
repository.fetch()
の戻り値はFutureで処理が終わるのを待つ必要があります。
そこで、store.dispatch
が呼ばれるまで待っています。
あとは、呼ばれているactionが正しいかを検証しています。
Reducerのテスト
テスト対象の関数です。
final hogeReducer = combineReducers<HogeState>([
TypedReducer<HogeState, HogeSucceededAction>(_healthCheck),
]);
HogeState _healthCheck(HogeState hogeState, HogeSucceededAction action) {
var entity = action.entity;
return HogeState(
fuga: entity.fuga,
);
}
テストケースです。これもstoreに必要な値を設定して、dispatchしています。
main() {
group('HogeReducer', () {
final store = new Store<AppState>(
appReducer,
initialState: new AppState(
hogeState: HogeState(
fuga: 'fuga',
),
),
);
test('fugafuga', () {
var entity = HogeEntity(
fuga: 'fugafuga',
);
store.dispatch(HogeSucceededAction(entity));
expect(store.state.hogeState.fuga, "fugafuga");
});
});
}
単純にstoreの値が正しいかを検証します。
Widgetのテスト
WidgetのテストでもMockを返す必要がありますが、要素が多いとmockitoを使うのは大変です。
なので、必ず同じ結果が返るスタブクラスを用意しておくと便利です。
こんな感じです。
class MockHogeRepository implements HogeRepository {
@override
Future<HogeEntity> fetch() {
return Future.value(HogeEntity(fuga: 'fugafuga'));
}
}
このクラスをテストで使います。
以下が、テスト対象のWidgetです。
onInitで、hogeState.fugaに「fugafuga」という文字列が入り、それをタップするとHoge2Pageに遷移するWidgetです。
class HogePage extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return HogePageState();
}
}
class HogePageState extends State<HogePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('hogeページ'),
),
body: StoreConnector<AppState, _ViewModel>(
onInit: (store) => store.dispatch(HogeAction()),
converter: _ViewModel.fromStore,
builder: (context, viewModel) {
return Column(
children: <Widget>[
InkWell(
key: ValueKey('ToHoge2Key'),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => Hoge2Page())),
child: Text(viewModel.fuga),
),
],
);
},
),
);
}
}
class _ViewModel {
final String fuga;
_ViewModel({
@required this.fuga,
});
static _ViewModel fromStore(Store<AppState> store) {
return _ViewModel(
fuga: store.state.hogeState.fuga,
);
}
}
遷移先のWidgetです。
class Hoge2Page extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('hoge2ページ'),
),
body: Text('ページ2'),
);
}
}
テストケースです。MiddlewareにはMockのrepositoryを渡しています。
必要な値を設定したstoreを利用します。
ポイントは、テスト対象のWidgetを、MaterialApp(home: HogePage())
のように設定しておくことです。
void main() {
final hogeRepository = MockHogeRepository();
final _hogeMiddleware = hogeMiddleware(hogeRepository);
testWidgets('HogePage', (WidgetTester tester) async {
var _store = Store<AppState>(
appReducer,
initialState: AppState(
hogeState: HogeState(),
),
middleware: _hogeMiddleware,
);
// 1.reduxのonInitが呼ばれる前
await tester.pumpWidget(
StatefulBuilder(
builder: (BuildContext buildContext, StateSetter setState) {
return StoreProvider(
store: _store,
child: MaterialApp(home: HogePage()),
);
},
),
);
// 2. 検証
expect(find.text('fuga'), findsOneWidget);
// 3. reduxのonInitが呼ばれた後
await tester.pumpWidget(
StatefulBuilder(
builder: (BuildContext buildContext, StateSetter setState) {
return StoreProvider(
store: _store,
child: MaterialApp(home: HogePage()),
);
},
),
);
// 4. 検証
expect(find.text('fugafuga'), findsOneWidget);
// 5. タップ対象を取得
var toHoge2 = find.byKey(ValueKey('ToHoge2Key'));
// 6. タップと遷移が終わるのを待つ。
await tester.tap(toHoge2);
await tester.pumpAndSettle();
// 7. 遷移したかの検証
expect(find.text('ページ2'), findsOneWidget);
// 8. テスト対象の画面に戻る
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('fugafuga'), findsOneWidget);
});
}
2で、onInitが呼ばれる前の検証をしています。
HogeState.fugaの初期値が「fuga」で、それが、Textに設定されています。それが一つあるという検証です。
4で、onInit内で呼ばれる、非同期処理のHogeRepository.fetch()
が終わった後の検証をしています。
5で、タップ対象を取得して、
6で実際にタップしています。 tester.pumpAndSettle
を呼ぶことで遷移が終わるのを待ちます。
8では定義していないはずの Icons.arrow_back
をタップしていますが、これはpushで遷移した場合に戻るボタンをflutterが付けるからです。(ちなみにfullscreenDialog=true
で遷移した場合は、Icons.close
が付きます。)
このようにwidgetのテストは、find
というオブジェクトで対象を検証したり、タップしたりとするのが基本です。
find
の戻り値は、Finder
クラスです。
実際のWidgetを取得したい場合は、以下のようにwidget
メソッドにFind
クラスを渡すことで取得できます。
Text text = tester.widget(find.text('fugafuga'));
Integration Test
正直、このテストは、費用対効果が薄く感じるので利用していませんが、少し触れておきます。
これはSimulatorなどにインストールして動作させるので遅いです。
まずは、設定です。
dev_dependencies:
flutter_driver:
sdk: flutter
test
ディレクトリと同じ階層に test_driver
を作成しておきます。
そこにテスト用のmainクラスを用意しておきます。
本当のmainメソッドを呼びますが、Repositoryなどをmockにしたい場合は設定できるように引数で渡せるようにします。
import 'package:hoge/main.dart' as app;
void main() {
enableFlutterDriverExtension();
app.main(MockHogeRepository());
}
// テスト以外で呼ばれるファイル。引数でモックを渡せるようにしておく。
void main([
HogeRepository hogeRepository,
]) {
hogeRepository = hogeRepository ?? HogeRepository()
・・・
}
テストケースは以下です。
void main() {
group('Hoge test', () {
FlutterDriver driver;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
if (driver != null) {
driver.close();
}
});
test('fuga表示', () async {
var key = find.text('fuga');
bool exist = await driver.waitFor(key);
expect(exist, isTrue);
});
test('hoge2に遷移', () async {
var key = find.text('fuga');
await driver.tap(key);
var page2Key = find.text('ページ2');
await driver.waitFor(page2Key);
await driver.waitForAbsent(key);
});
});
}
Widgetのテストと似ています。
なのでWidgetのテストを書いた方が起動も早いしお勧めです。
ただし、Http通信も含め、本当の意味でのIntegeration Testをしたい場合は使えると思います。
以下で動作させます。
$ flutter drive --target=test_driver/main_integration.dart
mockitoのTips集
Dartでの単体テストではmockitoを上手に扱えるかで生産性が変わります。
なので特にdart固有のmockitoのTipsを書いておきます。
戻り値がvoidのメソッドのmock化
/// モックにしたいクラス
class Hoge {
void doNothing() {}
}
/// テスト対象のクラス
class Fuga {
final Hoge hoge;
Fuga(this.hoge);
void fuga1() {
hoge.doNothing();
}
}
JavaのMockitoでは doNothing()
ですが、dart版はありません。
でも単純にthenReturn(null);
にすれば良いです。
class MockHoge extends Mock implements Hoge {}
main() {
test('fuga1()', () {
var mockHoge = MockHoge();
var target = Fuga(mockHoge);
when(mockHoge.doNothing()).thenReturn(null);
target.fuga1();
verify(mockHoge.doNothing());
});
名前付きパラメータのmock化
dartでの書き方は特殊なので気をつけましょう。知らないとハマります。
/// モックにしたいクラス
class Hoge {
String methodA({String nameA}) {
return "A";
}
}
/// テスト対象のクラス
class Fuga {
final Hoge hoge;
Fuga(this.hoge);
void fuga2() {
hoge.methodA(nameA: "fuga");
}
}
テストケースです。
test('fuga2()', () {
var mockHoge = MockHoge();
var target = Fuga(mockHoge);
when(mockHoge.methodA(nameA: anyNamed("nameA"))).thenReturn("1");
target.fuga2();
verify(mockHoge.methodA(
nameA: argThat(equals("fuga"), named: "nameA"),
));
});
例えば、引数を any
にしたい場合でも、any
にするとmockが呼ばれません。
代わりに、anyNamed()
を使いましょう。さらに引数には、名前付きパラメータ名を文字列で指定します。
ちゃんと値を検証したい場合は、argThat()
メソッドを使います。
引数には、以下を指定します。
- 検証したい引数の値
-
named
パラメータに名前付きパラメータ名を指定
引数がFunctionのメソッドのmock化
/// モックにしたいクラス
class Hoge {
int methodB(int Function() hogeFunc) {
return hogeFunc();
}
}
/// テスト対象のクラス
class Fuga {
final Hoge hoge;
Fuga(this.hoge);
int fuga3() {
return hoge.methodB(() {
return 1;
});
}
}
Javaの場合は、ArgumentCaptorを使って実行させていましたがdart版ではありません。
代わりに thenAnswer
の Invocation
クラスから実行することができます。
test('fuga3()', () {
var mockHoge = MockHoge();
var target = Fuga(mockHoge);
when(mockHoge.methodB(any)).thenAnswer((Invocation invocation) {
var func = invocation.positionalArguments.first;
return func();
});
// テスト対象
var actual = target.fuga3();
expect(actual, 1);
});
}
TODO 他にあれば追記していきます。