我流Flutter学習ステップ(5)単体テスト

Date:

Share post:

前回の投稿(我流Flutter学習ステップ(4)http)ではバックエンドとのHTTP通信機能を実装しましたが、今後も含めてフロントエンドの動作確認に実際のバックエンドを必要とすると言うのは効率的ではありません。
また、通信機能が単独で存在しても意味がなく、それを使用する側の機能も作り込まないと通信が行われません。
実際には何らかの画面操作の関連で通信を行うことになりますが、それらをある程度実装してからでないと通信機能の動作確認ができないと言うのでは非効率です。

Laravelではphpunitを使用した単体テストの仕組みが標準的に組み込まれていますが、実はFlutterでも同様に単体テストの仕組みが内包されていたようです。
と言うことで、今回はFlutterの単体テストに関して、特に前回実装したHTTP通信部分の動作検証方法について確認したいと思います。

パッケージのインストール

単体テストと行うには「flutter_test」というパッケージが必要なります。
ただ、同パッケージはFlutterプロジェクトの新規生成段階で合わせてインストールされるようです。
と言うことで、今回は特に新規で追加するパッケージはありません。

処理内容

テストケースの実装に関してはある程度ルールがあるようです。

まずテストケースを作成するディレクトリはFlutterプロジェクト直下の「test」と言うディレクトリ配下に置く必要があります。
「test」ディレクトリ配下であれば、さらにサブディレクトリを作成して階層的に環境構築することは問題ないようです。

テストファイル名は、被テストファイルが「abc_def.dart」であれば「abc_def_test.dart」のようにファイル名(拡張子除く)に「_test」を付けて命名するようですが、軽く試した限りでは前述のルールに則らないファイル名を付けても同テストケースの実行はできたので、この辺は約束事的な意味合いが強そうです。

とりあえず、特に必要がない限り被テストファイル「lib/abc/def/ghi.dart」に対応するテストケースは「test/abc/def/ghi_test.dart」として実装すると考えておけば良いかと思います。

今回の被テストファイルは前回の投稿で実装した「lib/repositories/user_repository.dart」とします。
よって、テストケースは「test/repositories/user_repository_test.dart」として実装します。

テストケース(基本)

まず、テストケースの書き方ですが、最もシンプルな構造としては以下になります。

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('テストケースに関する記述', () {
    // テスト内容
  });
}

テスト用パッケージ「flutter_test」を読み込んでおいて、main関数内でtest関数を呼び出します。
test関数には第一引数で文字列(テストケースの内容説明)、第二引数で無名関数を指定し、この無名関数内にテスト内容を記述します。

main関数内からtest関数は複数回呼び出せるので、必要なテストケースの数だけtest関数呼び出しを行います。
また、ある程度関連するテストケースに関してはそれをグルーピングすることもできます。

group('グループに関する記述', () {
  test('テストケース1に関する記述', () {
    // テスト1内容
  });
  test('テストケース2に関する記述', () {
    // テスト2内容
  });
}

上記のようにgroup関数を呼び出します。
引数はtest関数と同様で、内容を説明する文字列と実行内容を記述した無名関数になっています。
この無名関数内から複数のtest関数を呼び出すことで、それらをグルーピングすることができます。

このグルーピングは単にtest関数を呼び出しているだけではあまり意味がないように思いますが、テストケースに対する前処理(setUp)および後処理(tearDown)の実装を考えると存在感が出てきます。

例えば以下のようなケースを考えてみましょう。

import 'package:flutter_test/flutter_test.dart';

void main() {
  setUp(() {
    // setUp共通
  });
  tearDown(() {
    // tearDown共通
  });
  group('グループ1', () {
    setUp(() {
      // setUp1の処理
    });
    tearDown(() {
      // tearDown1の処理
    });
    test('テスト1-1', () {
      // テスト1-1の処理
    });
    test('テスト1-2', () {
      // テスト1-2の処理
    });
  });
  group('グループ2', () {
    setUp(() {
      // setUp2の処理
    });
    tearDown(() {
      // tearDown2の処理
    });
    test('テスト2-1', () {
      // テスト2-1の処理
    });
    test('テスト2-2', () {
      // テスト2-2の処理
    });
  });
}

setUpとtearDownは無名関数を引数とし、それぞれ対象となるテストケースの前後で当該無名関数が実行されます。

上記例ではmain関数直下でsetUpとtearDownを実行し、続く2つのグループ内で再度setUpとtearDownを実行していますが、この内容で各テストケースを実行すると、setUpおよびtearDownの実行順序は以下のようになります。

  1. setUp共通 → setUp1 → テスト1-1 → tearDown1 → tearDown共通
  2. setUp共通 → setUp1 → テスト1-2 → tearDown1 → tearDown共通
  3. setUp共通 → setUp2 → テスト2-1 → tearDown2 → tearDown共通
  4. setUp共通 → setUp2 → テスト2-2 → tearDown2 → tearDown共通

グループ外での設定に関しては当該main関数内の全テストケースに合わせて実行され、グループ内での設定に関しては当該グループ内のテストケースに関連してのみ実行されます。

上記テストケースの基本構造を踏まえて、以下に「test/repositories/user_repository_test.dart」を実装します。

テストケース(test/repositories/user_repository_test.dart)

ユーザー情報管理(lib/repositories/user_repository.dart)のテストケースとして、まずは正常系として2名分のユーザー情報を取得してくるケースを考えてみます。

ユーザー情報は単純にIDと名前の2つの要素から構成されており、レスポンスデータ全体としてはユーザー情報以外にバックエンドでの処理結果を表すコード「result」(正常時は0)と、エラー時のメッセージを格納する「message」(正常時はnull)を含んだ以下のマップ型データになるとの想定です。

{
  result: 0,
  message: null,
  data: {
    users: [
      {
        id: 1,
        name: 鈴木 太郎
      },
      {
        id: 2,
        name: 佐藤 次郎
      }
    ]
  }
}

実際のテストケースの実装内容は以下の通り。

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';

import 'package:sample/repositories/user_repository.dart';

void main() {
  group('Userリポジトリ', () {
    late ProviderContainer testContainer;

    // テスト用のコンテナをセットアップ
    setUp(() {
      testContainer = ProviderContainer();
    });

    // テスト終了後にコンテナを破棄する
    tearDown(() {
      testContainer.dispose();
    });

    test('indexの実行', () async {
      final client = MockClient((request) async {
        return http.Response(
            '{"result":0,"message":null,"data":{"users":[{"id":1,"name":"鈴木 太郎"},{"id":2,"name":"佐藤 次郎"}]}}',
            200,
            headers: {'content-type': 'application/json; charset=utf-8'});
      });

      final response = await http.runWithClient(
          () => testContainer.read(userRepositoryProvider).index(),
          () => client);

      expect(response['result'], 0);
      expect(response['message'], null);
      expect(response['data']['users'][0]['id'], 1);
      expect(response['data']['users'][0]['name'], '鈴木 太郎');
      expect(response['data']['users'][1]['id'], 2);
      expect(response['data']['users'][1]['name'], '佐藤 次郎');
    });
  });
}

ProviderContainer

まず、被テスト機能であるユーザ情報管理はUserRepositoryクラスとして実装され、同クラスへのアクセスはRiverpodのProviderである「userRepositoryProvider」経由で行われます。
よって、テストケース内でProviderにアクセスする手段が必要になるのですが、それがProviderContainerです。

以前の投稿「我流Flutter学習ステップ(2)RiverpodとGoRouter」においてはWidget内からProviderにアクセスする方法を紹介しましたが、その際はHookConsumerWidgetクラスを継承することでbuildメソッドの引数にWidgetRefインスタンスが渡されるようになり、このインスタンス経由でProviderにアクセスしていました。

テストケースにおいては上記のような手段の代わりにProviderContainerを用いる方法が提供されており、同インスタンス経由でProviderにアクセスできるようです。

まずはインスタンスを生成します。

testContainer = ProviderContainer();

上記で生成したインスタンス「testContainer」経由でProviderにアクセスする方法が以下になります。

testContainer.read(userRepositoryProvider).index()

WidgetRefの用法と同様に「testContainer.read(userRepositoryProvider)」により当該Providerが管理する状態(UserRepositoryインスタンス)を取得できますので、以降はそのプロパティやメソッドにアクセスできるようになります(上記例ではUserRepositoryインスタンスのindexメソッドを呼び出しています)。

なお、ProviderContainerに関しては使用後には明示的な削除が必要らしいです。

testContainer.dispose();

HTTPクライアントのモック

上記により、被テスト機能を使えるようになりましたが、このまま実行するとUserRepository内ではHTTPリクエストを実行しようとしますので、バックエンド側の準備ができていなければ当然ながら正しく動作しません。
よって、実際にはHTTPリクエストを行わず、バックエンドを実行したかのようにレスポンスを返す代替手段が必要になります。
それを実現しているのが「MockClient」と「http.runWithClient」です。

まず、http.runWithClientは以下のような記述になっています。

final response = await http.runWithClient(
    () => testContainer.read(userRepositoryProvider).index(),
    () => client);

第一引数で実際に実行したい処理(関数)を指定します。
今回はUserRepository.indexメソッドの呼び出しなので、それを行う無名関数を指定しています。

第二引数では、第一引数で指定した処理内でのhttpパッケージのメソッド(get等)実行に際して、実際のHTTPクライアントの代わりに使用される代替クライアント(正確には代替クライアントを生成する関数)を指定します。
今回は、MockClientインスタンスが代替クライアントとして使用されるように実装しています。

final client = MockClient((request) async {
  return http.Response(
      '{"result":0,"message":null,"data":{"users":[{"id":1,"name":"鈴木 太郎"},{"id":2,"name":"佐藤 次郎"}]}}',
      200,
      headers: {'content-type': 'application/json; charset=utf-8'});
});

MockClientとしては、今回はシンプルにJSON形式のレスポンスデータを返すのみの処理として実装しています。

http.runWithClientの戻り値は第一引数で指定した関数の戻り値です。
なお、UserRepository.indexメソッドの戻りはFuture型なので、非同期処理の完了をawaitで待ち合わせています。
その結果、responseはJSONをデコードしたMap<String, dynamic>型になります。

本テストケースとして確認すべきことはresponseの内容が期待された構造および値になっていることです。
この点に関してはexpectという関数が用意されており、これで期待される値と実際の値を比較します。
なお、機能的にはphpunitのassertEqualsと似ていますが、assertEqualsでは第一引数が期待する値、第二引数が実際の値という順序になっているのに対し、expectでは第一引数が実際の値、第二引数が期待する値という順序のようです。

expect(response['result'], 0);
expect(response['message'], null);
expect(response['data']['users'][0]['id'], 1);
expect(response['data']['users'][0]['name'], '鈴木 太郎');
expect(response['data']['users'][1]['id'], 2);
expect(response['data']['users'][1]['name'], '佐藤 次郎');

テストの実行

テストの実行は以下のようなコマンドで行えます。

# flutter test

上記では作成された全テストケースを実行しますが、下記のように対象ファイルを指定することで、特定のケースのみを実行することもできます。

# flutter test test/repositories/user_repository_test.dart

なお、エディタとしてVSCodeを使用している場合、VSCode上でテストを実行することもできます。

まとめ

ごく簡単なケースですが、通信に関わる処理のテストを通信に依存せずに行えるようになりました。

上記ではあくまで単体テストが目的であったので通信に依存しない形にしましたが、実際に通信を含めて動作確認を行いたい場合でもテストケースを使用することでWidgetの面倒な実装を行わずに動作させることができるので大変便利です。

なお、上記のようにテストケースを有効に扱うためにも、Widgetにおける画面描画に関わるような処理と、通信や関連するデータの加工・整形に関わるような処理を適切に分離しておくことが重要になります。

この辺はアーキテクチャの話になってきますので、将来もう少しFlutterの用法に馴染んだ段階で改めてアーキテクチャに関しても整理したいと思います。

Related articles

Laravel Filamentを使用した管理画面...

前回Breezeをインストールしたこと...

Laravel Filamentを使用した管理画面...

前回、filamentでのリソース作成...

Laravel Filamentを使用した管理画面...

前回、Filamentをインストールし...