FlutterでMockito(モック)を使ってWEB APIをテストする
対象読者
- Flutterでアプリ作成ができる
- テストについて基本をマスターしている
(テストについて基本的な勉強をしたい方には、) - ネットワークを使ったテストをしたいが、やり方が分からない
- 結果が毎回異なるので、テストできなくて困っている
目的
Flutterには3種類のテストがあります。ユニットテスト、Widgetテスト、インテグレーションテスト。しかし、ユニットテストとWidgetテストでネットワークを使用したテスト(Web APIを使用する、ネットワーク上の画像を表示する)を実施することができません。また、仮にネットワークが使えたとしても、Web APIは毎回異なる返信を返す可能性が高い(ランキングを伴う記事の一覧や明日の天気)ので、毎回テスト結果が同じことが期待される自動テストに不向きです。
この記事では、どのようにすれば、ネットワークが使えない状況でテストをしていくか、また、テスト結果をどのように固定させるか、を実現するための方法を解説します。
解決方法はモック
モックとは?
モック(Mock。摸擬の)というものを使います。アプリ開発で「モック」というと、中身は実装されていないけど、画面と画面遷移だけ実装されたものを実装前に作るものですね。テストの場合ですと、モッククラスという「同じ結果を返す実装がされたクラス」のことを指します。また、モックはテストの中で作成しますので、ネットワークを使用しません。ネットワーク先の結果を返す代わりに、固定の値を返します。
例えばお天気APIで明日の天気を取得する場合、アプリ実行時はちゃんとした明日の天気を取得するけど、テスト時はとりあえず「晴れ」に固定してテストしようぜ、ということです。
モックを使う利点
繰り返しとなりますが、モックを使う利点をまとめましょう。
- 結果を固定できる
- ネットワークの状況に影響されない
- ネットワークより高速に動作する
- 参照するクラスが未実装でも、自分の担当クラスがテストできる
「mockito」でモックを使う
DartやFlutterでモックを使用する場合「mockito」というパッケージを使用します。
ちなみにこのパッケージはJavaの「Mockito」というそのまんまなパッケージにインスパイアードされております。私は10年以上前からJavaのテストを実施するときはMockitoをいつも使ってました。
その他のパッケージ
「mockito」以外にも、「mocktail」というパッケージもあります。コードジェネレーションが不要なため、こちらを使用してました。しかし、2022年12月に使おうと思ったらバージョンの都合でインストールできませんでした(10ヶ月間更新されてないし)。バージョンを調整すればうまくいくかも知れませんでしたが、その根性はなく、素直にmockitoに移行しました。
他にも、mockitory、flutter_mockitory がありますが、21ヶ月更新されてないので、動作が期待できず、調査もしておりません。
方法
アプリは、できあがっているものとします。以下のステップをとります。
- mockitoをインストールする
- モックを使用したテストを作成する
- DI(Dependency Injection。依存性注入)を使って、アプリ内では本番クラス、テストでモッククラスを取得するように書き直す
モック対象のクラス紹介
モックでテストする対象のクラスは以下の通りです。GithubのレポジトリのAPIを使って「flutter」で検索して、ヒットしたレポジトリの数を返します。
class GithubApiRepository {
static const String kApiUrl =
'https://api.github.com/search/repositories?q=flutter';
Future<int> countRepositories() async {
final http.Client client = http.Client();
final response = await client.get(Uri.parse(kApiUrl));
final map = json.decode(response.body) as Map<String, dynamic>;
return map['total_count'] ?? -1;
}
}
検索結果は「https://api.github.com/search/repositories?q=flutter」にアクセスして頂ければ分かります。以下のようなJsonを返してきます。最初の30個のレポジトリ情報も返してきますが、このブログではガン無視します。ヒットしたレポジトリの数は「total_count」の値ですので、ネットワーク越しにそちらを取得して返します。
{
"total_count": 467417,
"incomplete_results": false,
"items": [
(中略)
}
ただ、このブログ作成中の結果なので、今実施すると異なる結果が返ってきます。そうなると、テストができません。そこでmockitoをインストールして、モックを使います。
mockitoのインストール
いつも通り、パッケージのインストールをします。ただ、mokito は開発中にしか使わないため、–devオプションをつけます。
コード生成で「build_runner」を使うので、そちらもインストールしておきましょう。
flutter pub add build_runner
flutter pub add mockito --dev
flutter pub get
モックの作成
モックを作成するクラスの指定
まず、モックを作成するクラスをテストソース内に mockitoパッケージの @GenerateNiceMocks アノテーションで指定します。このサンプルですと、http.Clientクラスと GithubApiRepositoryクラスのモックが作成されます。
import 'package:http/http.dart' as http;
import 'package:mockito/annotations.dart';
@GenerateNiceMocks([MockSpec<http.Client>(), MockSpec<GithubApiRepository>()])
main() {
}
モックの生成
作成するモックを指定した後に、Freezedなどでお馴染みのあのコマンドを実行します。
flutter pub run build_runner build
すると、「テスト名.mocks.dart」というファイルが生成されて、「Mock + 対象クラス」というモッククラスが作成されます。作成されるのは、モックを使うテストのファイル1つにつき、1つのファイルが作成されます。
モックのテスト
では、実際にモッククラスがどのような挙動をするのか、確認してみましょう。ここでは、http.Clientというクラスのモックを作成します。get(Uri)で指定したURIのデータをネットワーク上から取得しますが、モックを使って、テスト内にどのようなデータを返すのか定義します。
@GenerateNiceMocks([MockSpec<http.Client>()])
main() {
test('Mockのテスト', () async {
final client = MockClient();
when(client.get(any))
.thenAnswer((_) async => http.Response('{"total_count":467417}', 200));
expect(
(await client.get(Uri.parse(
'https://api.github.com/search/repositories?q=flutter')))
.body,
'{"total_count":467417}',
);
});
}
when(client.get(any))は「client.get()が実行されたとき」を意味します。anyは引数が何でも良いことを示します。引数によって結果を変更する方法は後述します。
thenAnswerでなにを返すかを定義します。この場合、「http.Response(‘{“total_count”:467417}’, 200)」を返すと定義しています。
モックを上記のように定義した結果、client.getの結果のbodyが「{“total_count”:467417}」と返っています。
DIを設定する
DI(依存性の注入)の設定をします。DIついて詳しくは、以下のページをご参照ください。
簡単に言うと、コード内では抽象クラスを指定して、実行時に生成するクラスを外部から指定する手段、となります。
このサンプルでは、
- アプリ実行時: きちんとネットワークアクセスできる本来のhttp.client
- テスト実施時: ネットワークアクセスせず、規定の結果を返すモッククラス
を切り替えができるようにします。
パッケージ GetItのインストール
flutter pub add get_it
アプリ側でDIの設定
アプリのソースに以下のように記載し、http.Clientクラスはhttp.Clientが使用されるように事前に登録しておきます。
void main(){
GetIt.I.registerLazySingleton<http.Client>(() => http.Client());
runApp(const MyApp());
}
アプリ側でインスタンス生成
いままで、「final http.Client client = http.Client();」とクラス名を直書きしていたのですが、DIを使って取得できるように以下のように記載します。このように書くことで、事前にDIで設定したクラスのインスタンスが作成されます。
final http.Client client = GetIt.I<http.Client>();
テスト側でインスタンスを生成
最初にモックを作成し、モックでなにを返すかを定義します。その後、DIでモックを返すように設定をしています。
final client = MockClient();
when(client.get(any))
.thenAnswer((_) async => http.Response('{"total_count":467417}', 200));
GetIt.I.registerLazySingleton<http.Client>(() => client);
モックを使用したテストを作成
テストは以下のようになります。外観上はアプリのコードと一緒ですねぇ。
final repository = GithubApiRepository();
final result = await repository.countRepositories();
expect(result, 467417);
内部の説明をします。ややこしくなりますが、countRepositories() の内部で、GetIt.I<http.Client>() を読んでいます。そのため、テストでは、DIでモッククラスが生成され、ネットワークから取得するはずのデータは常に「{“total_count”:467417}」のJsonを取得することになります。取得するJsonは常に同じため、 countRepositories() 内部で正しく処理がされているのであれば、常に「467417」を返すことになります。結果が同じため、テストが可能になりました。
まとめ
以上の手順で、DIとモックを使用して、ネットワーク越しに取得するはずのデータを固定させて、テスタブルなコードにする手順を勉強しました。
ネットワーク越しのデータだけでなく、同じような手順でデータベースやSharedPreferencesから取得するデータを固定化させるなどもできます。
その他のパッケージの場合
DIにGetItを使いたくないという方もいるかも知れません。GetXであれば、そちらのDIを使用すれば代替できます。
またRiverpodであれば、以下のようにすればモックに置き換えることができます。
ProviderScope(
overrides: [
httpClientProvider.overrideWithValue(MockClient())
],
child: const App(),
),
);
全ソース
ということで、全ソースを記載しておきます。
カウントアップアプリの「+」を押すと、Githubで「flutter」を検索した結果のレポジトリ数を表示します。
アプリコード
上記で非同期のメソッドのモックの作成方法を紹介してます。ついでに同期のメソッド getUrl() を追加して、そのテスト方法も後で見ます。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
void main() {
// DIの設定
GetIt.I.registerLazySingleton<http.Client>(
() => http.Client(),
);
GetIt.I.registerLazySingleton<GithubApiRepository>(
() => GithubApiRepository(),
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
final repository = GetIt.I<GithubApiRepository>();
repository.countRepositories().then((result) {
setState(() {
_counter = result;
});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'Flutter repository in github:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
class GithubApiRepository {
static const String kApiUrl =
'https://api.github.com/search/repositories?q=flutter';
Future<int> countRepositories() async {
final http.Client client = GetIt.I<http.Client>();
final response = await client.get(Uri.parse(kApiUrl));
final map = json.decode(response.body) as Map<String, dynamic>;
return map['total_count'] ?? -1;
}
String getUrl() {
return kApiUrl;
}
}
テストコード
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_salon/main.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'github_api_repository_test.mocks.dart';
@GenerateNiceMocks([MockSpec<http.Client>(), MockSpec<GithubApiRepository>()])
main() {
test('Mockのテスト', () async {
final client = MockClient();
when(client.get(any))
.thenAnswer((_) async => http.Response('{"total_count":467417}', 200));
expect(
(await client.get(Uri.parse(
'https://api.github.com/search/repositories?q=flutter')))
.body,
'{"total_count":467417}',
);
});
test('DIでモックを使用してテスト', () async {
// モックの設定
final client = MockClient();
when(client.get(any))
.thenAnswer((_) async => http.Response('{"total_count":467417}', 200));
// DIの設定
GetIt.I.registerLazySingleton<http.Client>(() => client);
// GithubApiRepositoryをモックを使用してテスト
final repository = GithubApiRepository();
final result = await repository.countRepositories();
expect(result, 467417);
});
test('thenResultとthenAnswerの違い', () async {
final repository = MockGithubApiRepository();
// without Future -> thenReturn
when(repository.getUrl()).thenReturn('resultUrl');
expect(repository.getUrl(), 'resultUrl');
// Future -> thenAnswer
when(repository.countRepositories()).thenAnswer((_) async => 1);
expect(await repository.countRepositories(), 1);
});
testWidgets('Widgetテストにモックを使う', (WidgetTester tester) async {
final repository = MockGithubApiRepository();
final answers = [1, 5];
when(repository.countRepositories())
.thenAnswer((_) async => answers.removeAt(0));
GetIt.I.registerLazySingleton<GithubApiRepository>(() => repository);
await tester.pumpWidget(const MyApp());
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsNothing);
expect(find.text('5'), findsOneWidget);
});
test('Mockの引数で返信を変更する', () async {
final client = MockClient();
when(client.get(
Uri.parse('https://api.github.com/search/repositories?q=flutter')))
.thenAnswer((_) async => http.Response('{"total_count":123}', 200));
when(client.get(
Uri.parse('https://api.github.com/search/repositories?q=dart')))
.thenAnswer((_) async => http.Response('{"total_count":456}', 200));
expect(
(await client.get(Uri.parse(
'https://api.github.com/search/repositories?q=flutter')))
.body,
'{"total_count":123}',
);
expect(
(await client.get(
Uri.parse('https://api.github.com/search/repositories?q=dart')))
.body,
'{"total_count":456}',
);
});
}
これだけあればなんとかなる、mockitoチートシート
mockitoの設定
mockitoのインストール
flutter pub add mockito --dev
flutter pub get
mockitoでモック生成するクラスの指定
import 'package:mockito/annotations.dart';
@GenerateMocks([http.Client, GithubApiRepository])
main() {
}
mokito でモックを生成
flutter pub run build_runner build
mockitoのモックの設定
取得する結果の指定
- 同期処理のメソッドの戻り値を設定
when(method).thenReturn(value) - 非同期処理のメソッドの戻り値を設定
when(method).thenAnswer((_)async=> value) - 例外を発生させる
when(method).thenThrow(Exception());
例えば、「認証システムへのログインに失敗して例外が発生したとき」の処理をテストすることができる。
final repository = MockGithubApiRepository();
// without Future -> thenReturn
when(repository.getUrl()).thenReturn('resultUrl');
expect(repository.getUrl(), 'resultUrl');
// Future -> thenAnswer
when(repository.countRepositories()).thenAnswer((_) async => 1);
expect(await repository.countRepositories(), 1);
// 例外が発生する
when(repository.getUrl()).thenThrow(Exception());
expect(() => repository.getUrl(), throwsA(isA<Exception>()));
取得する結果を連続で指定
Listで結果の一覧を作成して、リストの最初をremoveAt(0)で削除しながら、結果を返す
final answers = [1, 5];
when(repository.countRepositories())
.thenAnswer((_) async => answers.removeAt(0));
引数ごとに結果を変更
引数を変更することで、それに対応した結果を設定することができる
when(client.get(
Uri.parse('https://api.github.com/search/repositories?q=flutter')))
.thenAnswer((_) async => http.Response('{"total_count":123}', 200));
when(client.get(
Uri.parse('https://api.github.com/search/repositories?q=dart')))
.thenAnswer((_) async => http.Response('{"total_count":456}', 200));
expect(
(await client.get(Uri.parse(
'https://api.github.com/search/repositories?q=flutter')))
.body,
'{"total_count":123}',
);
expect(
(await client.get(
Uri.parse('https://api.github.com/search/repositories?q=dart')))
.body,
'{"total_count":456}',
);
引数がなんでも同じ結果を返せばいい場合は、anyを指定する
final client = MockClient();
when(client.get(any))
.thenAnswer((_) async => http.Response('{"total_count":123}', 200));
expect(
(await client.get(Uri.parse(
'https://api.github.com/search/repositories?q=flutter')))
.body,
'{"total_count":123}',
);
expect(
(await client.get(
Uri.parse('https://api.github.com/search/repositories?q=dart')))
.body,
'{"total_count":123}',
);
verifyで指定のメソッドが呼ばれた回数を調査
verifyを使えば、指定のメソッドが何回呼ばれたかを調査できる。verifyInOrderで想定の順番で実施されたかを確認できる。
一度verifyを呼ぶと、カウントがリセットされるのが注意点。
final uri = Uri.parse('url');
final client = MockClient();
when(client.get(uri))
.thenAnswer((_) async => http.Response('{"total_count":123}', 200));
verifyNever(client.get(uri));
client.get(uri);
verify(client.get(any)).called(1);
client.get(uri);
client.get(uri);
verify(client.get(uri)).called(2);
// 連続で呼ばれたことを確認
final uri2 = Uri.parse('url2');
when(client.get(uri2))
.thenAnswer((_) async => http.Response('{"total_count":123}', 200));
client.get(uri);
client.get(uri2);
verifyInOrder([
client.get(uri),
client.get(uri2),
]);
verifyZeroInteractions で呼ばれていないことを確認する
final uri = Uri.parse('url');
final client = MockClient();
when(client.get(uri))
.thenAnswer((_) async => http.Response('{"total_count":123}', 200));
verifyZeroInteractions(client);
client.get(uri);
expect(() => verifyZeroInteractions(client), throwsA(isA<TestFailure>()));
verifyNoMoreInteractionsの前に呼ばれないことを確認
final uri = Uri.parse('url');
final client = MockClient();
when(client.get(uri))
.thenAnswer((_) async => http.Response('{"total_count":123}', 200));
client.get(uri);
expect(() => verifyNoMoreInteractions(client), throwsA(isA<TestFailure>()));
client.read(uri);
expect(() => verifyNoMoreInteractions(client), throwsA(isA<TestFailure>()));
verify(client.get(any));
verify(client.read(any));
verifyNoMoreInteractions(client);