【Flutter】DIとMock を使ってWEB APIをテストする + mockito チートシート コード付

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/mockito.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ついて詳しくは、以下のページをご参照ください。

[Dart/Flutter]DI (Dependency Injection/依存性の注入)をGetItで実現する 実アプリとテスト環境で、使用するクラスを変更する

簡単に言うと、コード内では抽象クラスを指定して、実行時に生成するクラスを外部から指定する手段、となります。
このサンプルでは、

  • アプリ実行時: きちんとネットワークアクセスできる本来の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/mockito.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);