LoginSignup
4
1

Flutter テスト (Flutter Roadmap Testing)

Last updated at Posted at 2024-02-29

はじめに

Flutterを網羅的に学習するにあたってRoadmapを使って学習を進めることにしました。

この記事では、Flutter初学者やこれからFlutterを学習し始める方に向けて、テストについてまとめています。

RoadmapはFlutterだけでなく、他の言語やスキルのロードマップも提供されており、何から学習して良いか分からないと悩んでいる方にとって有用なサイトになっています。
ぜひRoadmapを利用して学習してみてください。

Roadmapとは

簡潔に言えば、Roadmap.shは学習者にとってのガイドブックであり、学習の方向性を提供する学習ロードマップサイトです。

初心者から上級者まで、ステップバイステップでスキルを習得するための情報が提供されています。

学習の進め方が分かりやすく示されているだけでなく、個々の項目に参考資料やリソースへのリンクも提供されているので、学習者は目標を設定し、自分自身のペースで学習を進めることができます。

Testing

FlutterロードマップTestingでは以下の12のサイトが紹介されています。興味のある方はぜひお読みください。

テストとは

テストコードとは一言でいうと書いたコードが正常に作動しているかの確認をするためのコードです。

業務での開発は 1 つのバグが大きな障害につながる可能性があります。それを事前に防ぐためにプログラムレベルでチェックするという作業がテストコードの実行になります。

なぜテストコードを書くのか?

なぜ工数を割いてまで、テストコードを書くのでしょうか?その理由は大きく分けて4つあります。

  • バグを減少させる
  • リファクタリングを実施しやすくする
  • コードの品質を高める
  • 仕様理解の促進に繋がる

1. バグを減少させる

バグが発見される主な要因は、予期せぬ挙動や期待しない結果が検知されることです。テストコードを書いておくことで、機械的に動作の確認が行えるため、検知の抜け漏れがなくなり、バグの減少に繋がります。

テストコードは実行後、すぐに開発者に対してフィードバックを行うため、変更によって引き起こされた問題に早期に気付くことができます。早期に検知できることの何が良いかというと、修正が容易な段階で改修を加えることができるということです。これにより、バグが本番環境に反映される前に検知することができます。

2. リファクタリングを実施しやすくする

テストコードを書いておくと、リファクタリングや追加機能実装の際に修正箇所のクラスやメソッドの挙動が変わっていないことが確認できるので、容易に改修を実施することができます。

もし、テストコードが書かれていなかった場合、リファクタリング後、正常に動作していることを確認するために、PR の差分を見て、影響箇所を特定したり、手動にて関連する全機能をテストすることになります。

テストコードを書いておくと、改修後のチェックとして「テストコードが通るか」をチェックするだけで既存機能が壊れていないかを確認することができます。

3. コードの品質を高める

テストコードを書く習慣がついてくると「テストしやすいコード」を意識するようになります。テストしやすいコードがどのようなものかというとコード全体が疎結合(クラスやメソッド間に余計な依存関係が発生していない状態)な状態ということです。

疎結合なシステムを構築することによって、クラスやメソッド間での結合度が下がり、保守性が向上するだけでなく、追加実装や改修も容易になるので、拡張性も向上します。

テストコードを書くこと(テストしやすい開発を行うこと)によって、自然とコードの品質を高めることができるのです。

サンプルコード

密結合なコード (テストしにくいコード)
// 密結合なクラス
class TestA {
  void doSomething() {
    print('Class A is doing something.');
  }
}

class TestB {
  TestA _classA;

  TestB() {
    _classA = TestA();
  }

  void performAction() {
    _classA.doSomething();
  }
}

void main() {
  TestB testObject = TestB();
  testObject.performAction();
}

上記の例では、TestB クラスが TestA クラスに直接依存しており、TestBのインスタンスを作成するときに TestA も直接インスタンス化されています。これによって、クラス間の結合が非常に強くなり、テスト時に TestA クラスの挙動が影響を与える可能性が高まります。 一方で以下のコードは疎結合なコードです。

疎結合なコード (テストしやすいコード)
// 疎結合なクラス
class LowCouplingClassA {
  void doSomething() {
    print('Class A is doing something.');
  }
}

class LowCouplingClassB {
  TestA _classA;

  TestB(TestA classA) {
    _classA = classA;
  }

  void performAction() {
    _classA.doSomething();
  }
}

void main() {
  TestA testObjectA = TestA();
  TestB testObjectB = TestB(testObjectA);
  testB.performAction();
}

この例では、 TestB クラスが TestA クラスを直接インスタンス化せず、代わりにコンストラクタを介して TestA のオブジェクトを受け取っています。これによって、クラス間の結合が緩和され、TestA クラスの変更が TestB クラスに影響を与えにくくなります。また、疎結合なコードのため、テスト時には TestA の挙動をモックやスタブと容易に交換できます。

4. 仕様理解の促進に繋がる

テストコードがあるとコードの理解促進につながります。テストコードはテストの説明文と処理を実行した後の期待値を定義する必要があるため、テストコードを読むだけでも、メソッドが呼び出され、処理が完了した後、どのように値が変わるのかまでを確認することができるのです。

例えば、「ユーザーがタスクを完了すると、そのタスクが完了済みとしてマークされる」という仕様があり、その条件を満たすために、タスクが完了すると isCompleted フラグを true に設定するメソッドを作成したとします。以下はその例です。

Taskクラス
// タスククラス
class Task {
  String title;
  bool isCompleted;

  Task(this.title, {this.isCompleted = false});

  void completeTask() {
    isCompleted = true;
  }
}

上記の例をもとにテストコードを作成すると以下のようになります。

テストコード
...
  test('タスク完了確認、 正常系', () {
    Task task = Task('Flutterアプリ開発');

    ... 
    
    // 処理実行
    task.completeTask();

    // 期待値確認
    expect(task.isCompleted, true);
  });
...

このテストコードでは、 completeTask メソッドを呼び出し、その後 isCompleted が正しく true に変更されることを確認しています。 このコードを見るだけでも completeTask メソッドがタスクの完了状態を更新すること、 isCompleted フラグが完了状態を管理していることが仕様として明確になります。

また、テストコードを書くということは、仕様を実際にコードとして実装するということですので、実装者も仕様の理解促進に繋がります。

総合的に見て、テストコードを書くことで、コードの品質や開発効率が向上して、プロジェクト全体の成功につながります。開発初期の段階から継続的なメンテナンスまでと考えると、テストコードを書くことに対する工数は、投資する価値があると言えるのではないでしょうか。

テストコード実行手順

テストコードを書いて、実行するまでには大きく分けて以下の4つの手順があります。

  1. flutter_test パッケージを追加する
  2. テストコードを格納するディレクトリを作成する
  3. テストコードの作成
  4. テスト実行

1. flutter_test パッケージを追加する

テストコードを書くために、 flutter_test パッケージを利用します。 Flutter のプロジェクトを作成すると、デフォルトで pubspec.yaml に登録されているため、通常はそのまま使えば問題ないです。

pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

2. テストコードを格納するディレクトリを作成する

テストコードを格納する場所は /test ディレクトリ以下の階層になります。ここにテストの内容を適切なファイル単位で作成していき、基本方針としてテストファイルは _test.dartで終わるファイルを配置するようにします。

例えば、以下のようなディレクトリ構成で、 lib ディレクトリが作成されているとします。 lib ディレクトリ以下の階層をそのまま test ディレクトリ以下に構築して、ファイル名を _test.dartにすることで、適切なファイル構成になります。

my_project/
├── lib/
│   ├── models/
│   │   └── user.dart
│   ├── services/
│   │   └── user_service.dart
│   └── utils/
│       └── string_utils.dart
└── test/
    ├── models/
    │   └── user_test.dart
    ├── services/
    │   └── user_service_test.dart
    └── utils/
        └── string_utils_test.dart

3. テストコードの作成

ファイルを作成したら次に行うことは、テストコードの作成です。ここでは説明を割愛しますので、詳しくは下の Unitテストの書き方 を見てください。

4. テスト実行

テストコードの実装が完了したら、最後に flutter test をターミナルで実行して、テスト結果を確認します。以下は 3 件のテストコードを書いた後、 flutter test を実行した結果です。 このように "All tests passed" と表示されていれば、全てのテストを通過したことになります。

$ flutter test
00:02 +3: All tests passed! 
  • 00:02 : テストが実行された総時間(ここでは2秒かかったことを示しています)
  • +3: : すべてのテストが成功したことを示す値、成功であれば、 + 失敗した場合は - が表示されます。
  • All tests passed! : すべてのテストが成功したというメッセージです。この表示は、プロジェクト内のすべてのテストケースが正常に実行され、期待通りに動作していることを表しています。

成功した場合でも、各テストケースごとに成功した旨の詳細なログが表示されるので、テストケースごとの実行結果や所要時間、成功数、失敗数などを確認することができます。

Unitテスト

Unitテストとは

Unitテスト(ユニットテスト / 単体テスト)とは、プログラムの比較的小さな単位(ユニット)で、個々の機能が正常に動作しているかどうかを検証するためのテストです。

このテストでは、機能単体で正しく動作するかを確認するので、他のコンポーネントや外部の依存関係は考慮せず、対象の単位だけに焦点を当てます。

Unitテストの書き方

Unitテスト書く際は、まず AAAパターン (Arrange-Act-Assert) というパターンを遵守することです。

AAA パターンとは、テストコードを Arrange(準備)、Act(実行)、Assert(確認)の3つのフェーズに分けて書くことを言います。 AAAパターンを採用することで、テストが明確になり、テストコードが読みやすくなります。

具体例として、加算(足し算)・減算(引き算)の例を考えてみましょう。以下のコードは足し算・引き算の関数を定義した MathOperations クラスです。

math_operations.dart
class MathOperations {
  int add(int a, int b) {
    return a + b;
  }

  int subtract(int a, int b) {
    return a - b;
  }
}

上記のクラスをもとにテストコードを作成するとなると以下のようなテストコードが完成します。

math_operations_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:tester/main.dart';

void main() {
  late MathOperations mathOperations;

  setUp(() {
    mathOperations = MathOperations();
  });

  test('add', () {
    // Arrange
    const a = 5;
    const b = 3;

    // Act
    final result = mathOperations.add(a, b);

    // Assert
    expect(result, equals(8));
  });

  test('subtract', () {
    // Arrange
    const a = 8;
    const b = 3;

    // Act
    final result = mathOperations.subtract(a, b);

    // Assert
    expect(result, equals(5));
  });
}

コード解説

main関数
void main() {
  late MathOperations mathOperations;

  setUp(() {
    mathOperations = MathOperations();
  });
  • main 関数でテストスイート1を実行しています。
  • late キーワードを使用して mathOperations 変数を宣言し、 setUp 関数を使ってテストケースごとに MathOperations クラスの新しいインスタンスを作成しています。
  • setUp 関数は各テストが実行される前に実行され、テストケース間で値を共有したり、初期化を行う際に使用されます。
add関数
  test('add', () {
    // Arrange
    const a = 5;
    const b = 3;

    // Act
    final result = mathOperations.add(a, b);

    // Assert
    expect(result, equals(8));
  });
  • test 関数を使用して、 add 関数のテストケースを定義しています。
  • const を使用して定数 ab を設定し、 mathOperations.add(a, b) を呼び出して計算を行います。
  • expect 関数を使用して、計算結果が期待通りの結果 equals(8) になることを検証します。
subtract関数
  test('subtract', () {
    // Arrange
    const a = 8;
    const b = 3;

    // Act
    final result = mathOperations.subtract(a, b);

    // Assert
    expect(result, equals(5));
  });
  • add 関数と同様に、 subtract 関数のテストケースを定義しています。
  • const を使用して定数 ab を設定し、 mathOperations.subtract(a, b) を呼び出して計算を行います。
  • expect 関数を使用して、計算結果が期待通りの結果 equals(5) になることを検証します。

テストケース作成のポイント

1. テスト観点の明確化

テストケースを作成する際は、まず必要なテスト観点を明確にして、方針を定め、効率的に作業を進めていきます。テストにかけられる工数は基本的に限られていて、量をこなせば品質が上がるものではないので、必要なテストだけを実施することが求められます。

2. ユニットテストの観点

ユニットテストを作成する際は、いくつかの基本的な観点を取り入れることで、一定のテストケースをカバーすることができます。以下は一般的なユニットテストのテスト観点です。

ロジックチェック

もっとも基本的な観点です。メソッドが適切な結果を返しているか、特定の条件で分岐され、処理されているかを確認します。例えば引数の値を偶数か奇数かチェックする関数があるとします。

ロジックチェック例
// 偶数か奇数かを判定する関数
bool isEven(int number) {
  return number % 2 == 0;
}

この関数をテストする場合、以下の観点を考慮する必要があります。

  • 偶数の場合
  • 奇数の場合
  • ゼロの場合

これらの観点を基に、テストコードを作成することで、 isEven メソッドが期待通りのロジックで動作しているかを確認できます。以下は、上記のテストケースを網羅したテストコードです。

import 'package:flutter_test/flutter_test.dart';

void main() {
  group('isEven', () {
    test('isEven trueの場合', () {
      expect(isEven(4), true);
      expect(isEven(0), true);
    });

    test('isEven falseの場合', () {
      expect(isEven(5), false);
    });
  });
}
境界チェック

境界チェックとは、対象のプログラムが境界(範囲)を超えているかどうかを確認するためのテスト観点です。一般的に境界値は、最小値、最大値、ゼロ、範囲の端などを指します。

具体的な例として、メートルをセンチメートルに変換する関数での境界チェックを考えてみましょう。

境界チェック例
// メートルをセンチメートルに変換する関数
double metersToCentimeters(double meters) {
  if (meters < 0) {
    throw ArgumentError('正の値で入力してください');
  }

  // 1メートル = 100センチメートル
  return meters * 100.0;
}

この関数において、境界チェックは以下の観点を考慮する必要があります。

  • 負の値の場合
  • ゼロの場合
  • 正の値の場合

上記の観点を基に、関数が境界値に対して正しく例外をスローするか、正しい値を返すかどうかを確認します。以下は、上記のテストケースを網羅したテストコードです。

import 'package:flutter_test/flutter_test.dart';

void main() {
  group('metersToCentimeters', () {
    test('metersToCentimeters 負の値', () {
      expect(() => metersToCentimeters(-5.0), throwsArgumentError);
    });

    test('metersToCentimeters ゼロ', () {
      expect(metersToCentimeters(0.0), 0.0);
    });

    test('metersToCentimeters 正の値', () {
      expect(metersToCentimeters(2.5), 250.0);
    });
  });
}
エラー処理

エラーチェックとは、プログラムが実行される際に発生する可能性のあるエラーに対して、エラーが発生した際も、正常に実行を継続できるよう処理を書いているかを確認するための観点です。

例えば、リスト内の要素の平均値を計算する関数を考えてみましょう。入力として空のリストが与えられた場合は計算が行えないため、これを防ぐためには分岐処理を行いエラーを検出して、適切な処理を実装する必要があります。以下はエラー処理を書いたサンプルコードです。

// 平均値を計算する関数
double calculateAverage(List<double> numbers) {
  if (numbers.isEmpty) {
    throw ArgumentError('リストを空にすることはできません');
  }

  double sum = 0.0;
  for (var number in numbers) {
    sum += number;
  }

  return sum / numbers.length;
}

この関数に対してエラーチェックを含むテストケースを考えます。

  • 空のリスト(エラー)の場合
  • リストに値が入っている(正常)場合
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('calculateAverage', () {
    test('calculateAverage リストが空の場合(エラー)', () {
      expect(() => calculateAverage([]), throwsArgumentError);
    });

    test('calculateAverage リストに値がある場合(正常)', () {
      expect(calculateAverage([2.0, 4.0, 6.0]), 4.0);
    });
  });
}

3. 多角的な視点を入れる

プログラムの担当者だけでテストケースを作成するのではなく、他の開発者などにも確認してもらうことで、死角になっている検証観点も洗い出すことができます。そのため、実際の現場では、単体テストのテストケースを作成した後は、他の開発者のレビューを受けることをルールとしていることがよくあります。

基本メソッド

test()

テストケースを定義するためのメソッドです。必須のプロパティは、以下の2つになります。

  • description: テストケースの説明を文字列で指定します。この説明はテスト結果の表示やテスト実行時のログに使用されます。
  • body: テストの本体であるテストコードを記述します。
description body 例
test('テストケースの説明 (description)', () {
  // テストの本体 (body)
});

上記の必須プロパティ以外にも、オプションで他の設定を加えることができます。以下はオプションプロパティの説明です。

testOn:
  • テストを実行するプラットフォームを指定します。例えば、特定のOSやブラウザでのみ実行するテストを定義する際など。
  • プラットフォームに対する条件が満たされていない場合、テストはスキップされます。
testOn例
test('テストケースの説明 (description)', () {
  // テストの本体 (body)
}, testOn: 'chrome');
timeout:
  • テストが完了するまでに許容される最大時間を指定します。
  • 設定する時間は Duration オブジェクトで指定します。
  • デフォルトでは null で設定されているため、タイムアウトは無効です。
timeout例
test('テストケースの説明 (description)', () {
  // テストの本体 (body)
}, timeout: Timeout(Duration(seconds: 5)));
skip:
  • テストをスキップするかどうかの制御を行います。 "true" または "文字列" を指定すると、テストがスキップされます。
  • "文字列"を指定した場合は、記載した文字列が表示されるため、スキップ理由などを文字列で記載することが一般的です。
  • "false"または指定しない場合は、通常通りテストが実行されます。
skip例
test('テストケースの説明 (description)', () {
  // テストの本体 (body)
}, skip: 'このテストはまだ実装されていません');
tags:
  • テストにタグを割り当てることができるようになり、テストの実行をフィルタリングする際などに使用されます。
  • カスタムのタグを指定すると、 flutter test --tags コマンドで特定のタグを対象にテストを実行できるようになります。

tagsを使用するには、プロジェクトルートに dart_test.yaml ファイルを作成し、タグを定義する必要あります。(以下参照)

dart_test.yaml
tags:
  タグ名:
onPlatform:
  • 特定のプラットフォームでのみ実行されるテストを指定します。
  • 設定する際はプラットフォーム名を key として、タイムアウトやスキップの処理を value とするマップを設定します。
onPlatform例
test('テストケースの説明 (description)', () {
  // テストの本体 (body)
}, onPlatform: {
  'windows': Skip('Windowsではスキップ'),
  'linux': Timeout(Duration(seconds: 5)),
});
retry:
  • テストが失敗した場合に、リトライする回数を指定します。
  • デフォルトは 0 に設定されているため、リトライは行いません。
retry例
test('テストケースの説明 (description)', () {
  // テストの本体 (body)
}, retry: 3);

expect()

ユニットテストでアサーションを行うためのメソッドです。必須のプロパティは、以下の2つになります。

  • actual: テストで得られた実際の結果や値を指定します。
  • matcher: 期待する結果を表すマッチャーを指定します。
expect(actual, matcher);

test メソッドと同様に、上記の必須プロパティ以外にも、オプションで他の設定を加えることができます。以下はオプションプロパティの説明です。

reason:
  • expect メソッドの最後に追加することができ、アサーションが失敗した場合に表示されるメッセージを指定することができます。
expect(actual, matcher, reason: 'アサーションが失敗した理由');
skip:
  • test() メソッドのオプションプロパティ skip と同様に、テストをスキップするかどうかの制御を行います。
  • "true"または"文字列"を指定すると、テストがスキップされます。
  • "文字列"を指定した場合は、記載した文字列が表示されるため、スキップ理由などを文字列で記載します。
  • "false"または指定しない場合は、通常通りテストが実行される。
test('テストケースの説明 (description)', () {
  // テストの本体 (body)
  expect(actual, matcher, skip: 'まだ実装されていません');
}, skip: 'このテストはまだ実装されていません');

matcherとは?

"matcher"とは、期待する結果や条件を表すためのオブジェクトです。 matcher を使用して、テストケースで得られた実際の結果と期待する結果を比較し、アサーションを行います。

matcherの使用例
void main() {
  // equals マッチャー: 2つのオブジェクトが等しいことを確認します。
  test('equals sample', () {
    int actual = 5;
    int expected = 5;
    expect(actual, equals(expected));
  });

  // isTrue・isFalse マッチャー: 真偽値が true または false であることを確認します。
  test('isTrue・isFalse sample', () {
    bool condition = true;
    expect(condition, isTrue);
  });

  // isNull マッチャー: 値が null であることを確認します。
  test('isNull sample', () {
    String? value;
    expect(value, isNull);
  });

  // contains マッチャー: コレクションが指定した要素を含んでいることを確認します。
  test('contains sample', () {
    List<int> numbers = [1, 2, 3, 4, 5];
    int value = 3;
    expect(numbers, contains(value));
  });

  // isA マッチャー: オブジェクトが指定した型であることを確認します。
  test('isA sample', () {
    expect('Hello', isA<String>());
  });

  // throwsA マッチャー: 指定した例外をスローすることを確認します。
  test('throwsA sample', () {
    expect(() => throw Exception('Test Exception'),
        throwsA(const TypeMatcher<Exception>()));
  });

  // hasLength マッチャー: コレクションが指定した長さであることを確認します。
  test('hasLength sample', () {
    expect('Hello', hasLength(5));
  });

  // isEmpty マッチャー: コレクションが空であることを確認します。
  test('isEmpty sample', () {
    List<int> emptyList = [];
    expect(emptyList, isEmpty);
  });
}

上記のサンプルコードは、さまざまな状況に対応するためのマッチャーの一部です。他にも利用可能なマッチャーは数多く存在するため、特定の要件に合わせて利用することができます。

その他

オプションで test 関数と組み合わせて使用できる関数は、以下のようなものがあります。

group():
  • テストスイート内で関連するテストをグループ化するために使用します。
  • 関連するテストをグループ化することができるので、テストが整理され可読性が向上されます。
group('グループの説明', () {
  // グループ内のテストケースやサブグループ
});
setUp():
  • 各テストケースの前に共通の処理を実行するために使用されます。例えば、オブジェクトの初期化やデータの設定などに利用されます。
setUp(() {
  // 共通の処理
});
setUpAll():
  • setUp() メソッドと同様にテストの実行前に呼び出されますが、 setUpAll() メソッドはテストグループ内のすべてのテストの開始前に1回だけ実行されます。
  • 大量のデータの準備や一度だけ行いたい処理がある場合に使用します。
setUpAll(() {
  // 共通の処理
});
tearDown():
  • 各テストケースの後に共通の処理を行うために使用します。例えば、オブジェクトの破棄やリソースの削除などに使用されます。
tearDown(() {
  // 共通の処理
});
tearDownAll():
  • tearDown() メソッドと同様にテストの実行後に呼び出されますが、 tearDownAll() はテストグループ内のすべてのテストの終了後に1回だけ実行されます。
  • 大量のデータのクリーンアップや一度だけ行いたい処理がある場合に使用します。
tearDownAll(() {
  // 後始末
});

Mockito

mockito とは、Dart用のモックライブラリのことを指します。モックとは実際のオブジェクトをテスト環境で代替するためのものです。

mockito を使用することで、モックオブジェクトを作成することができます。作成されたモックは実際のオブジェクトを模倣して、正しく機能しているかのテストを行うために使用されます。

Flutterプロジェクト内で mockito を使用するには mockitoパッケージをインストールする必要があります。下記のリンクから mockitoパッケージをインストールしましょう。 また、コード生成による、モックの作成を行う必要があるため、 build_runner パッケージをインストールします。

mockitoのアノテーション

mockito パッケージには、モックオブジェクトを簡単に生成するためのアノテーションが提供されています。主に、 @GenerateNiceMocks@GenerateMocks がその代表例です。これらを使用することで、手動でモッククラスを作成する手間を省くことができます。

@GenerateNiceMocks :

モックインスタンスが呼び出されたときの挙動を設定していない場合、例外をスローせず、デフォルトの値(空文字、0、空のリスト、など)を返してくれるようになり、モッククラスの実装を手動で行う必要がなくなります。

import 'package:mockito/annotations.dart';

// アノテーションを使ってモックを生成するファイルにつけます
@GenerateNiceMocks([MockSpec<ClassName>()]) 
import 'file_name_test.mocks.dart'; // モックを生成したいファイルのパス

void main() {
  // main関数やテストコードの中で生成されたモックが利用できる
  var mockExample = MockExample();
}

公式では↑こちらのアノテーションを使用することが推奨されています。

@GenerateMocks :

@GenerateMocks アノテーションもモックの生成に使用されますが、生成されたモックは未実装のメソッドが呼ばれると例外をスローします。これにより、モックメソッドが実装されていない場合に早期にエラーを検知できます。

import 'package:mockito/annotations.dart';

// アノテーションを使ってモックを生成するファイルにつけます
@GenerateMocks([Example])
import 'your_file.dart'; // モックを生成したいファイルのパス

void main() {
  // main関数やテストコードの中で生成されたモックが利用できる
  var mockExample = MockExample();
}

公式でのサンプルコードを元に使い方を解説していきます。以下は公式で例として挙げられている Cat クラスになります。このクラスは猫を表すもので、猫の一般的な行動や特性を表しています。

main.dart
class Cat {
  String? sound() => 'Meow';
  bool? eatFood(String? food, {bool? hungry}) => true;
  Future<void> chew() async => print('Chewing...');
  int? walk(List<String>? places) => 7;
  void sleep() {}
  void hunt(String? place, String? prey) {}
  int lives = 9;
}

上記のコードを元に mockito を使用してUnitテストを書くとなるとまず、モックオブジェクトを作成する必要があります。以下のコードのように @GenerateNiceMocks アノテーションを使用して Cat クラスのモックオブジェクトを作成してください。

main_test.dart
@GenerateNiceMocks([MockSpec<Cat>()])
import 'main_test.mocks.dart';

void main() {
  final cat = MockCat();
 ...

MockSpec を使用することで、モックオブジェクトに特定の条件を設定し、その条件が満たされた場合にのみモックが期待通りの挙動を示すよう設定できます。特定の条件下でモックの挙動を変更したり、特定の引数に基づいて異なる結果を返すことが可能になります。

アノテーションで対象のクラスを指定した後 build_runner コマンドを実行して、モックオブジェクトを自動生成しましょう。

flutter pub run build_runner build --delete-conflicting-outputs

モックオブジェクト生成後、以下の mockito パッケージで使用することのできるメソッドを使用して、テストコードを書いていきます。

when()

  • モックメソッドが呼び出されるときの挙動を設定します。 when メソッドを使用することで、メソッドがどのように呼ばれたとき、どのような結果を返すかを指定することができます。
  • 基本的には when() メソッド単体で呼び出されることはなく、他のメソッドと組み合わせて使用されます。

モックインスタンスに対して、 when() メソッドの使用がない場合、また when() メソッド単体の場合だと、デフォルトの戻り値が返却されます。

@GenerateNiceMocks([MockSpec<Cat>()])
import 'main_test.mocks.dart';

void main() {
  final cat = MockCat();
  
  test('when test', () {
      expect(cat.sound(), isNull); 
  });
  ...
thenReturn:
  • thenReturn() メソッドは、 when() メソッドと組み合わせて使用され、メソッドが呼ばれたときに返却される値を指定することができます。
thenReturn例
void main() {
  final cat = MockCat();
  
  test('thenReturn test', () {
      when(cat.sound()).thenReturn("Purr");
      expect(cat.sound(), "Purr");
        
      when(cat.sound()).thenReturn("Meow");
      expect(cat.sound(), "Meow");
  });
  ...
thenThrow:
  • thenThrow() メソッドは、 when() メソッドと組み合わせて使用され、メソッドが呼ばれたときに例外をスローするように設定することができます。
thenThrow例
test('thenThrow test', () {
    when(cat.lives).thenThrow(RangeError('Boo'));
    
    expect(() => cat.lives, throwsRangeError); // test OK
});
thenAnswer:
  • thenAnswer() メソッドは、 when() メソッドと組み合わせて使用され、呼び出し時に動的な結果を返すために使用されます。
  • 関数を渡すことで、特定の引数に基づいて異なる結果を返すことができます。
  • 単純に固定値を返す場合は thenReturn() を、動的に変化する値を返す場合は thenAnswer() を使用します。
thenAnswer例
test('thenAnswer test', () {
    var responses = ["Purr", "Meow"];
    when(cat.sound()).thenAnswer((_) => responses.removeAt(0));

    expect(cat.sound(), "Purr");
    expect(cat.sound(), "Meow");
});

verify

  • モックインスタンスで呼び出されたメソッドが、期待通りに呼び出されているかを確認するためのメソッドです。
  • DBの書き込みが正しい回数で行われているか、API呼び出しが適切な順序で行われているか、などを確認することができます。
verify例
group('verify', () {

    test('verify test', () {
        cat.sound();

        // 指定の処理が呼び出されかどうかを確認
        verify(cat.sound()); 
    });

    test('verify.called test', () {
        cat.sound();
        cat.sound();
        cat.sound();

        // 指定の処理が、指定した回数だけ呼び出されかどうかを確認
        verify(cat.sound()).called(3); 
    });
});

一度 verify で検証されたメソッドは、それ以降検証対象から除外されるので注意が必要です。

test('verify test', () {

  // テスト対象の処理を実行
  cat.eatFood("fish");

  // 1回目の検証
  verify(cat.eatFood("fish")).called(1); // test OK

  // 2回目の検証
  verify(cat.eatFood("fish")).called(1); // test NG
});
verifyInOrder():
  • メソッドが特定の順序で呼ばれたことを検証するために使用されます。
  • メソッドの呼び出し順序が重要な場合に使用します。
verifyInOrder例
test('verifyInOrder test', () async {

    cat.eatFood("Milk");
    cat.sound();
    cat.eatFood("Fish");

    // 複数の処理が、特定の順序で実行されたことを確認
    verifyInOrder([
      cat.eatFood("Milk"),
      cat.sound(),
      cat.eatFood("Fish")
    ]);

   // test NG
   // verifyInOrder([cat.sound(), cat.eatFood("Milk"), cat.eatFood("Fish")]); 
});
verifyNever():
  • 特定の処理が一度も実行されていないことを確認するために使用されます。
test('verifyNever test', () {

   cat.sound();
   cat.sleep();

   // 指定の処理が、一度も呼ばれてないことを確認
   verifyNever(cat.chew()); 
});
verifyZeroInteractions():
  • モックオブジェクト内の処理が、一度も呼び出されていないことを確認するために使用されます。
test('verifyZeroInteractions test', () {
   // 指定のモックオブジェクト内の処理が、一度も呼ばれてないことを確認
   verifyZeroInteractions(cat); 
});

group内で共通のモックオブジェクトを使用する場合は他のtestでモックオブジェクトを使用していた場合、テストNGになるため、注意が必要がです。

group('verify', () {
   final cat = MockCat();

   test('verify test', () {
       cat.sound();

       verify(cat.sound()); // test OK
   });

   test('verifyZeroInteractions test', () {
       // 他のtest内でcatは使用されているため、テストNGとなる。
       verifyZeroInteractions(cat);
   });
});
verifyNoMoreInteractions():
  • モックオブジェクト内の特定の処理の検証が全て終わった後、それ以外に処理が呼び出されていないことを確認するために使用されます。
group('verifyNoMoreInteractions verify', () {
    final cat = MockCat();

    test('test1', () {
        cat.sound();
        verify(cat.sound());

        verifyNoMoreInteractions(cat); // test OK
    });

    test('test2', () {
        cat.sound();
        verify(cat.sound());
        cat.sound();

        // verify後にcatが使用されているため、テストNG
        verifyNoMoreInteractions(cat);
    });

    test('test3', () {
        cat.sound();
        cat.sleep();
        verify(cat.sound());

        // cat.sleep()に対するverifyが実行されておらず、
        // cat.sleep()が検証対象から除外されていないためテストNG
        verifyNoMoreInteractions(cat);
    });
});

Argument matchers

mockito パッケージには、Argument matchers(引数マッチャー)と呼ばれるメソッドがあります。Argument matchersを使用することで、特定の引数パターンで、スタブ化や検証を行うことができるようになります。

any
  • 引数を任意の値とすることができます。
  • 名前付き引数の場合は、anyNamed()を使用します。
test('any matchers test', () {
    // 引数が何であっても、eatFoodメソッドがfalseを返すように設定
    when(cat.eatFood(any, hungry: anyNamed('hungry'))).thenReturn(false);

    // さまざまな引数でメソッドを呼び出し、常にfalseが返されることを確認
    expect(cat.eatFood('fish', hungry: true), isFalse);
    expect(cat.eatFood('meat', hungry: false), isFalse);
    expect(cat.eatFood('vegetable'), isFalse);
});
argThat
  • 引数が特定の条件を満たす場合のみ、スタブ化や検証を行うようにします。
test('argThat matcher', () {
    // 引数foodが'fish'である場合にのみ、trueを返すように設定
    // equalsやcontains等のmatcherを併用することも可能
    when(cat.eatFood(argThat(equals('fish')))).thenReturn(true);

    // 'fish'の引数でメソッドを呼び出し、trueが返されることを確認
    expect(cat.eatFood('fish'), isTrue);

    // 'meat'の引数でメソッドを呼び出しても、スタブ化されていないため初期値であるnullが返される
    expect(cat.eatFood('meat'), isNull);
});
captureAny
  • 任意の引数を記録し、後で検証等を行うことができます。
test('captureAny matchers', () {
    cat.eatFood('fish');
    cat.eatFood('meat');

    expect(verify(cat.eatFood(captureAny)).captured, ["fish", "meat"]);
});

カバレッジ算出

カバレッジとは、どれだけのコードがテストされたかを示す指標です。カバレッジを見ることによって、視覚的にどのくらいテストを行なって、どの程度網羅されているのかを確認することができます。

まずは、Flutterでカバレッジを算出できるように設定を行います。

1. テストとカバレッジの取得

ターミナルで以下のコマンドを実行して、テストカバレッジを取得します。

$ flutter test --coverage

これにより、 coverage ディレクトリが生成され、 lcov-report フォルダ内に coverage/lcov.info というカバレッジレポートが生成されます。

2. lcovのインストール

ファイルが生成された後、これを lcov というカバレッジテストツールを使ってHTMLに変換します。 lcov はHomebrew経由でインストール可能です。以下のコマンドを入力して、 lcov をインストールしてみてください。

$ brew install lcov

インストールが完了したら、作成したファイルをHTMLに変換するために以下のコマンドを入力します。

// lcov.info -> HTMLへ変換
$ genhtml coverage/lcov.info -o coverage/html

コマンドを実行したディレクトリに coverage/html/index.html が生成されているので、これをブラウザで開くとカバレッジファイルが確認できます。

3. gitignoreへの追加

生成した lcov.info ファイルと lcov で生成したHTMLファイルはGitでバージョン管理をする必要がなく、必要な時生成して確認すれば良いので、 .gitignore ファイルに coverage フォルダを追加してテストカバレッジと関連するファイルをバージョン管理しないように設定します。

4. カバレッジレポートの閲覧

生成されたカバレッジレポートを閲覧するためには、ブラウザで coverage/lcov-report/index.html を開くことで、どの部分がテストされており、どの部分がテストされていないかを視覚的に確認できます。

参考資料

  1. テストスイート
    目的や対象ごとに複数のテストケースをまとめたことを言います。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1