Flutterでユニットテストをする

 ・ 5 min

photo by Hai Nguyen on Unsplash

ずっと後回しにしてきたテストに挑戦しようと思います。
domain -> data -> application -> presentationの順で急いで機能を作ってきましたが、少しずつ統合していく中でエラーが出ることがあり、テストを通じて各項目が正しく作られているか確認してから進めようという考えが出てきました。

Flutterでのテスト#

Flutterでテストする方式は、他でも多く使われているGiven-When-Thenパターンを使いやすくしています。そしてFlutterはUIのテストもサポートしています。

ここではUnit testをどのように行ったか説明します。VS Codeでテストしたいファイルを選択してください。右クリックするとGo to Testsオプションがあるので押してください。ファイルがない場合は下にSnackbarが表示されてファイルを作成するか聞かれます。いい点は、testフォルダの下に同じパスで{元のファイル名}_test.dartで作成されることです。

そうして作成されると、以下のような内容でファイルが作成されるはずです。

import 'package:flutter_test/flutter_test.dart';
 
void main() {
  testWidgets('when to meet service ...', (tester) async {
    // TODO: Implement test
  });
}

すぐにtestWidgetsが作成されましたが、今はwidget以前のロジックをテストするためにgroup関数やtest関数を使います。main関数の中で主に入る関数はtestWidgettestgroupです。

testWidgetはウィジェットをテストするための関数です。
testはロジックをテストするための関数です。
groupは複数のtestやtestWidget関数をまとめて実行する時に囲む関数です。

3つの関数すべて、2つの必須引数を含めていくつかの引数がありますが、基本的な形は似ています。callbackやbody関数の型が全く同じというわけではありません。

void main() {
	testWidgets(description, callback);
	test(description, body);
	group(description, body);
}

testWidgetsのサンプルはプロジェクト作成時に生成されたwidget_test.dartファイルがあるので今は扱いません。後でUIテストが必要になった時、記事を書きながらサンプルを作ってみます。今回はrepositoryクラスをgroupとtest関数で使うサンプルを作ります。

Given-When-Thenパターン#

Given-When-Thenパターンを使うと言ったので、この方式が何か簡単に説明します。テストを必ずこの方式で行う必要はありません。テストに決まった一つの方法はありません。単純に値だけ確認するテストをすることもあれば、Seleniumのようなパッケージを使って予想された動作を実行するかテストすることもあります。

Given-When-Thenは準備-実行-検証のプロセスだと思えば大丈夫です。
Given: そのシナリオでテストしたい行動を開始する前に、状況を設定する部分です。テストのための前提条件と考えても大丈夫です。
When: 決めた動作を実行する部分です。テストが必要な対象関数が入る必要があります。
Then: 決めた動作によって期待される変化を確認する部分です。テスト後の結果が予想した結果と一致するか確認するプロセスです。

以下は他の方が作ったGiven-When-Thenパターンのサンプルで、理解の助けになるでしょう。

Feature: User trades stocks
  Scenario: User requests a sell before close of trading
    Given I have 100 shares of MSFT stock
       And I have 150 shares of APPL stock
       And the time is before close of trading
 
    When I ask to sell 20 shares of MSFT stock
 
     Then I should have 80 shares of MSFT stock
      And I should have 150 shares of APPL stock
      And a sell order for 20 shares of MSFT stock should have been executed

Thenではテスト結果が成功かどうかの確認だけでなく、失敗の場合も失敗であることを確認するプロセスが必要です。失敗を予想したのに成功する場合、致命的なエラーが発生する可能性がありますから。

テストサンプル#

repositoryに作った関数の中にRemoteWhenToMeetRepositoryというオブジェクトがあります。内部でFirebaseのFirestoreにdomainオブジェクトデータを保存する関数addWhentToMeetがあります。これをテストすると仮定します。

class RemoteWhenToMeetRepository implements WhenToMeetRepository {
  const RemoteWhenToMeetRepository({required this.fs});
  final FirebaseFirestore fs;
 
  CollectionReference get _whenToMeetCollection => fs.collection('when_to_meet');
 
  @override
  Future<void> addWhenToMeet(WhenToMeet whenToMeet) {
    return _whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson());
  }
 
  @override
  Future<WhenToMeet?> getWhenToMeet(WhenToMeetID id) {
    return _whenToMeetCollection.doc(id).get().then((doc) {
      final data = doc.data();
      if (data == null) {
        return null;
      }
      return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
    });
  }
}

これをテストする方法はいくつかあります。

  1. 保存するオブジェクト(WhenToMeet)を生成し、FirebaseFiresotre.instanceをtest関数内部で呼び出した後、保存する関数をtest内部で実行して期待結果と比較する
  2. 保存するオブジェクト(WhenToMeet)を生成し、RemoteWhenToMeetRepositoryオブジェクトを生成した後、addWhenToMeet関数を呼び出して期待結果と比較する

まだこの方式に慣れていないので、最初からRemoteWhenToMeetRepositoryを使うよりは一つずつ進めるのがいいと思います。RemoteWhenToMeetRepositoryはFirebaseFirestoreを外部から注入する必要があるため、まずFirestoreに保存されるかを確認します。

when関数を使う場合は、dependenciesにmockitoを追加してください。
flutter pub add mockito

void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final fs = FirebaseFirestore.instance;
    final whenToMeetCollection = fs.collection('when_to_meet');
 
    // when
    when(await whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson()));
 
    // 結果をFirestoreから取得
    final expectResult = await whenToMeetCollection.doc(whenToMeet.id).get().then((doc) {
      final data = doc.data();
      if (data == null) return null;
      return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
    });
 
    // then
    expect(whenToMeet, expectResult);
  });
}

これを実行するとエラーが出ます。Firebaseの初期化が行われていないために発生するエラーです。main関数やtest関数のどこかにawait Firebase.initializeApp();を追加しても別のエラーが出ます。FlutterFireを参照すると、ユニットテストにはfakesを使うよう案内されています。fake_cloud_firestoreが必要なのでパッケージを追加します。
flutter pub add fake_cloud_firestore

image

Fakeを使っているため実際には保存されません。ではなぜFakeやMockというものをテストで使うのでしょうか?
最大の理由は、その動作までテストする必要がないからと言えます。その部分はテスト範囲に含めないということです。そしてMock、Fake、Spy、Stub、Dummyを総称してTest Doubleと呼びます。この用語は映画撮影時にスタントマンが俳優の代わりに危険な役割を担うStunt doubleに由来しているそうです。

final fs = FakeFirebaseFirestore();の部分だけ変更(importも忘れずに)したらテストが通りました。
前のコードと1行だけ異なりますが、テスト環境でもうまく動きます。

void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final fs = FakeFirebaseFirestore();
    final whenToMeetCollection = fs.collection('when_to_meet');
 
    // when
    when(await whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson()));
 
    // 結果をFirestoreから取得
    final expectResult = await whenToMeetCollection.doc(whenToMeet.id).get().then((doc) {
      final data = doc.data();
      if (data == null) return null;
      return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
    });
 
    // then
    expect(whenToMeet, expectResult);
  });
}

Test Double#

Test DoubleはGeminiの力を借りて簡潔にまとめます。
Test Doubleを使う理由

  • 外部依存性の隔離: 外部依存性によってテスト結果が不安定になることを防ぎます。
  • テスト環境の制御: テストに必要な特定の状況や入力を簡単に設定できます。
  • テスト実行速度の向上: 実際のオブジェクトより高速に実行されるFakeオブジェクトなどを使ってテスト速度を上げることができます。
  • テストカバレッジの拡大: 外部依存性によりテストしにくい部分までテストできます。

Dummy

  • 役割: 単に必要なパラメータを埋めるために使用されるオブジェクトです。実際には使用されず、テスト対象のコードが正常に動作するか確認するのに使われます。
  • 例: 関数のパラメータとしてオブジェクトが必要だが、実際にはそのオブジェクトを使用しない場合にDummyオブジェクトを渡すことができます。

Fake

  • 役割: 実際のオブジェクトと類似した動作をしますが、簡略化または制限された機能を持つオブジェクトです。実際のオブジェクトを使うのが難しかったり複雑な場合、またはテスト環境で高速な実行が必要な場合に使われます。
  • 例: 実際のデータベースの代わりにメモリベースのFakeデータベースを使ってテストできます。

Stub

  • 役割: 特定の値や動作を事前に定義して返すオブジェクトです。テスト対象コードの特定の入力に対する出力を予測・検証するのに使われます。
  • 例: 外部API呼び出しをStubオブジェクトに置き換えて、APIレスポンスに応じたテスト対象コードの動作を検証できます。

Spy

  • 役割: 実際のオブジェクトと類似した動作をしながら、呼び出し情報を記録するオブジェクトです。テスト対象コードが他のオブジェクトとどのように相互作用するか確認するのに使われます。
  • 例: 特定のメソッドが呼び出されたか、呼び出し時に渡された引数は何かなどをSpyオブジェクトを通じて確認できます。

Mock

  • 役割: 特定の振る舞いを期待し検証するオブジェクトです。テスト対象コードが他のオブジェクトと予想通りに相互作用するか確認するのに使われます。
  • 例: 特定のメソッドが特定の回数呼び出されたか、特定の順序で呼び出されたかなどをMockオブジェクトを通じて検証できます。

まとめてみると、TDDをするにはGiven-When-ThenパターンとTest Doubleを知っている必要があり、Unit Test、Integration Test、Code Coverageの概念と使い方を知ってこそうまく使えるなと感じます。

Firestoreでテストしてみたので、次はRemoteWhenToMeetRepositoryでテストしてみようと思います。既に主要な関数で実装を済ませていたので、関数を呼び出すだけになりコード量は減りました。

void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final fs = FakeFirebaseFirestore();
    final repository = RemoteWhenToMeetRepository(fs: fs);
    // when
    when(await repository.addWhenToMeet(whenToMeet));
 
    // 結果をFirestoreから取得
    final expectResult = await repository.getWhenToMeet(whenToMeet.id);
 
    // then
    expect(whenToMeet, expectResult);
  });
}

もしrepository変数を頻繁に使いそうなら、setUp、setUpAll関数でグローバル変数のように使ってもいいでしょう。テストする方法は様々で、状況によって使えない場合もありますが、今回の機会でやり方が分かって良かったです。

もしVS Codeを使っている場合、テスト実行のショートカットをお教えします。Start Debugging(ショートカットF5)が先に実行された状態で、実行したいtest関数内にカーソルを置いてF5ボタンを押すか、cmd + ;を押してからcmd + cを入力するとその関数が実行されます。

image

試したこと#

Mockオブジェクトをbuild_runnerで作ることも試しましたが、getWhenToMeet関数の結果が以前と同一ではありませんでした。これはもう少し調べる必要がありますが、参考までにコードを残しておきます。

@GenerateNiceMocks([MockSpec<RemoteWhenToMeetRepository>()])
import 'remote_when_to_meet_repository_test.mocks.dart';
 
void main() {
  test('should save a when_to_meet domain data', () async {
    // given
    final whenToMeet = WhenToMeet.test();
    final repository = MockRemoteWhenToMeetRepository();
    // when
    when(await repository.addWhenToMeet(whenToMeet));
 
    // 結果をFirestoreから取得
    final expectResult = await repository.getWhenToMeet(whenToMeet.id);
    // then
    expect(whenToMeet, expectResult);
  });
}

The road of excess leads to the palace of wisdom.

— William Blake


他の投稿
Flutterでアイコンと名前を変更する 커버 이미지
 ・ 1 min

Flutterでアイコンと名前を変更する

iPhoneでFlutterを実行する 커버 이미지
 ・ 1 min

iPhoneでFlutterを実行する

Flutterでfvmを適用する 커버 이미지
 ・ 1 min

Flutterでfvmを適用する