6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

みなさん、テストしてますか?
私の現場ではテストはマストです😇
実装と合わせてテストも必ず書いてプルリクエストを提出しています。

初学者や実務未経験者が必ずと言っていいほどつまずくのがここだと思います。
いろいろな記事を見ながら学習してみてはつまずき、現場で書いて泣きました😢

そんな苦労を無駄にしないためにも、自分自身が忘れないようにするためにも記事に書き起こしていこうと思います。

記事の対象者

  • unit_testについて知りたい方
  • unit_testを習得したい方

記事を執筆時点での筆者の環境


[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.90.1)

ソースコード

テストコードについて
今回紹介するテンプレートコードやサンプルコード意外にも
実際に実装した内容に対するテストコードを鋭意作成中です💪
ひとまず動くテストしかpushはしていないので、気になる方は覗いてみてください👀

※ 学習中なので、内容は変更される場合があります🙇‍♂️

1. テストってなあに?

ソフトウェア開発におけるテストとは、プログラムが期待通りに動作するかを確認するためのプロセスです。
これには、バグやエラーの発見、機能の検証、パフォーマンスの評価などが含まれます。
テストにはいくつかの種類があり、以下のようなものがあります

  1. 単体テスト(Unit Testing)
    プログラムの個々の部分(関数やメソッドなど)をテストします
  2. ウィジェットテスト(Widget Testing):UIコンポーネントであるWidgetを個別に、または複数組み合わせた形でテストします
  3. 結合テスト(Integration Testing)
    複数の部分が組み合わさったときに正しく動作するかをテストします
  4. システムテスト(System Testing)
    システム全体が要求仕様通りに動作するかをテストします
  5. 受け入れテスト(Acceptance Testing)
    ユーザーや顧客がシステムを受け入れるかどうかを確認するためのテストです

エンジニアでは主に1、2、3を行います。
2はFlutter特有のテストですね。
4はQAチーム、または外部のQA会社に委託する場合が多いと思います。
5はいわゆるユーザーにテストしてもらうものでβテストなどです。

今回はそんな中でFlutterにおける単体テストについてお話ししていきます。

2. ユニットテストの準備

2-1. フォルダとファイルの作成

テストのファイルはtestフォルダー内に作成します。
原則はテスト対象のファイルが置かれているディレクトリ構成に則ってディレクトリとテストファイルを作成します。
そのほうが探す際にわかりやすいからです。

例えば以下のrepository.dartとprovider.dartのテストを書きたいとなった場合を見てみましょう。


lib
├── data
│   ├── local_sources
│   │   ├── shared_preference.dart
│   │   └── shared_preference.g.dart
│   └── repositories
│       └── key_value_repository
│           ├── provider.dart // <= 💡 ここをテストしたい
│           ├── provider.g.dart
│           └── repository.dart // <= 💡 ここをテストしたい

その場合は以下のようにディレクトリを作り、ファイルを作成します。


test
├── data
│   └── repositories
│       └── key_value_repository
│           ├── provider_test.dart // <= 😀
│           └── repository_test.dart // <= 😀

スクショで見るとこのような形になります。

スクリーンショット 2024-06-22 18.13.56.png

テストファイルは名称の末尾を必ず_test.dartとしないとテスト用のファイルだと認識されずにテストが実行できません。
ファイル作成後は必ず確認しましょう!

今回は実際の実装内容は使わずサンプルを使って説明していきます。


test
└── sample
    ├── sample_test.dart
    └── unit_test_template_test.dart

2-2. 実行環境

次にテストファイル内の実行環境を作成します。
私が作成したテンプレートをみながら解説します。

test/sample/unit_test_template_test.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  late ProviderContainer container;

  setUp(() {
    container = ProviderContainer();
  });
  tearDown(() {
    container.dispose();
  });
  // TODO: 使用するときにskipを外す
  group('apple test', skip: true, () {
    test('titleができる', () {});
    test('titleができる', () {});
    test('titleができる', () {});
  });
  // TODO: 使用するときにskipを外す
  group('orange test', skip: true, () {
    test('titleができる', () {});
    test('titleができる', () {});
    test('titleができる', () {});
  });
}


main()

まずはプログラムを実行する上で欠かせない、main()関数です。
この中に必要な関数を書いていきます。

定数

テスト全体で使う定数があるのであれば最初に定義します。
このテンプレートにはlate ProviderContainer container;を定義しています。
ほとんどのプロジェクトではRiverpodを使用していると思いますのでテンプレートにも載せました。
当然、Riverpodを使っていないプロジェクトまたはファイルものあるので、必要なければ記載しなくて大丈夫です。

setUp()


 setUp(() {
    container = ProviderContainer();
  });
  

この関数は後に出てくる個々のtest()実行前に呼ばれます。
主な用途はテストごとの設定を行います。
よくあるのはProviderContainerやダミーデータ、mockのスタブを設定するのに使用します。
引数が関数になっているので使用する際はその無名関数内に処理を書きます。

main()関数の直下に配置すると全てのテストが実行される前に実行されます。
また、後述するgroup()内に記述するとそのgroup()内のテストが実行される前に実行されます。

tearDown()


  tearDown(() {
    container.dispose();
  });

この関数はsetUp()とは反対にそれぞれのテストが終わる直前に呼び出されます。
主な用途はリソースの解放です。
よくあるのはProviderContainerの解放、StreamControllerの購読終了などです。

尚こちらもsetUp()と同じくmain()関数の直下に配置すると全てのテストの終了直前に実行されます。
また、後述するgroup()内に記述するとそのgroup()内のテストの終了直前に実行されます。

group()


  group('apple test', skip: true, () {
    test('titleができる', () {});
    test('titleができる', () {});
    test('titleができる', () {});
  });

テストをグルーピングするのに使います。
主に設定したい前提条件を分けたい場合に使いますが、単に見通しが良くなるように分けておきたい場合でも使用します。
一括した設定でテストを行う場合は使用しなくても良いです。

第一引数にグループの説明を文字列で入れます。
第二引数は無名関数で{}内にテストを書いていきます。
なお、オプションとして引数の一つにskipがあります。
bool値のtrue、または文字列を入れることでflutter testの集計対象から除外することができます。
テストをスキップしないのであればこの引数は削除して使用します。

test()


test('titleができる', () {

});

実際のテストを書いていきます。
第一引数にテストの説明を文字列で入れます。
第二引数は無名関数で{}内にテスト内容を書いていきます。

尚、test()関数にもオプションでskipが用意されています。

2-3. その他の詳細

今回は説明を割愛しますが、テスト用の関数としてsetUpAll()tearDownAll()があります。
また、group()test()にはskip以外のオプションも用意されています。
詳しくはこちらの記事で解説されていますので、興味がある方はご覧になってみてください。

3. テストを書く、検証する

ここからは実際にテストを書いてみます。
テストをするということは、その対象クラスのメソッドが期待した動きや処理をおこなっているかを確かめることです。
どうやって確かめるのかをサンプルを元に見ていきましょう。
まずは以下に全体のコードを載せておきます。

test/sample/sample_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preference_sample/applications/log/logger.dart';

void main() {
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preference_sample/applications/log/logger.dart';

void main() {
  // テスト全体で使う変数があればここで宣言
  const someBool = true;
  const someString = '文字です';
  const someInt = 1;
  const someIntList = [1, 2, 3];
  const someNull = null;

  // テスト全体で使う設定
  // main直下に配置すると、全てのテストの最初に実行される
  setUp(() {
    logger.d('main直下で宣言したsetup');
  });
  // テストが終了するごとに呼ばれる
  // StreamControllerやProviderContainerなどのリソース解放を行う
  tearDown(() {
    logger.d('main直下で宣言したtearDownを実行');
  });

  group('sample testその1', skip: 'サンプルのためカバレッジから除外', () {
    setUp(() {
      logger.d('group直下で宣言したsetup');
    });

    int? testMethod({required bool? flag}) {
      return switch (flag) {
        true => 1,
        false => 2,
        null => null,
      };
    }

    tearDown(() {
      logger.d('main直下で宣言したtearDownを実行');
    });

    test('マッチャーを使ったテスト', () {
      // 第一引数に検査対象、第二引数に期待値を入れる
      // 期待値をMatcher型で定義されたものから選べる
      expect(someBool, isTrue);
      expect(someString, isNotEmpty);
      expect(someInt, isNonZero);
      expect(someIntList, isList);
      expect(someNull, isNull);
    });
    test('期待値を直接入れる場合', () {
      expect(someBool, true);
      expect(someString, '文字です');
      expect(someInt, 1);
    });
    test('比較対象の値を少し編集する場合', () {
      expect(someInt < 2, isTrue);
      expect(someIntList.length, 3);
    });
    test('少し複雑な期待値をマッチャーで表現する', () {
      expect(someInt, lessThan(2));
      expect(someIntList, hasLength(3));
    });
    test('メソッドをテストする', () {
      int? result;

      result = testMethod(flag: true);
      expect(result, 1);

      result = testMethod(flag: false);
      expect(result, 2);

      result = testMethod(flag: null);
      expect(result, isNull);
    });
  });
  group('sample testその2', skip: false, () {
    setUp(() {
      logger.d('二つ目のgroup直下で宣言したsetup');
    });
    // 引数のskipに値を入れるとflutter testコマンドでのtestにスキップされる
    // このフォルダ内でテストの実行はできる
    // カバレッジにまだ含めたくない場合などで使うといい。
    test('スキップされるテスト1', skip: '今回のリリースには関係ないのでスキップ', () {
      expect(someBool, isTrue);
    });
    // falseにするとスキップされない <= 引数自体消す
    test('スキップされるテスト2', skip: true, () {
      expect(someBool, isTrue);
    });
  });
}

3-1. except()で確認する


 test('マッチャーを使ったテスト', () {
      // 第一引数に検査対象、第二引数に期待値を入れる
      // 期待値をMatcher型で定義されたものから選べる
      expect(someBool, isTrue);
      expect(someString, isNotEmpty);
      expect(someInt, isNonZero);
      expect(someIntList, isList);
      expect(someNull, isNull);
    });

expectを和訳すると「期待する」となります。
expect()関数では第一引数に検査対象の値を、第二引数に検査対象の値に期待する値を入れます。
expect()の定義を見ると第二引数はdynamic matcherでなんでも入れられるようになっています。

スクリーンショット 2024-06-22 21.54.12.png

上記の例ではそれぞれにMatcher型の値を入れています。

一つ例をとってみると、expect(someBool, isTrue);が検証している内容はsomeBoolという値はtrueである、ということです。

ここでこのテストブロックの左上にあるRunを押すとこのテストだけを実行して結果を確認することができます。
テストブロック内の全ての条件があっている場合に左の緑のチェックマークがつきます。
つまりテスト成功です👍🏻

スクリーンショット 2024-06-22 22.00.21.png

逆にこのテストブロック内のexpect()が一つでもあっていなければテストは失敗します。
試しに`expect(someBool, isFalse);で実行してみると以下のようになります。

スクリーンショット 2024-06-22 22.06.47.png

今回は本来テストでは使わないloggerでログを出しているので少々見づらいのですが、テストの横に赤いバッテンマークがついてテストが失敗したことを明示しています。

さらにスクショ上部の赤枠や画面下の「テスト結果」タブにも表示がありますが、どこのテストがなぜ失敗したかが確認できます。

Expected: false => falseになることを期待したけど
Actual: => 結果はtrueだった
test/sample/sample_test.dart 43:7 => 問題の場所は43行目のコード

main()の上の'Run'を押すと全てのテストが実行されます。
group() の上の'Run'を押すとそのグループ内のテストが実行されます。

3-2. Matcherは沢山ある

ものすごく沢山あります。
覚えるのは正直しんどいので、よく使うものは覚えてそれ以外は「あれ、これあるかなぁ?」と思った時に上記の公式ページで確認するようにすればいいと思います。

少し複雑で使いそうなものとして以下のものでしょうか。


    test('少し複雑な期待値をマッチャーで表現する', () {
      expect(someInt, lessThan(2));
      expect(someIntList, hasLength(3));
    });

lessThan(2) => 2より小さい
hasLength(3) => 要素が3こある

3-3. そもそもMatcherを使わない

チャブ台を返すようですが、Matcher型の値を使わずに直接値を指定する、または検証対象の値を少し編集すれば同じことは表現できます。
なぜなら先ほども紹介しましたが、第二引数はdynamic matcherなので、なんでも入れられるからです。


    test('期待値を直接入れる場合', () {
      expect(someBool, true);
      expect(someString, '文字です');
      expect(someInt, 1);
    });

    test('比較対象の値を少し編集する場合', () {
      expect(someInt < 2, isTrue);
      expect(someIntList.length, 3);
    });
    

Matcher型の値を使った方が可読性としてはいいのですが、そこで時間を使って悩むようであればひとまず値を入れてしまっても良いと思います。

3-4. メソッドのテストをしてみよう


    int? testMethod({required bool? flag}) {
      return switch (flag) {
        true => 1,
        false => 2,
        null => null,
      };
    }
    

本来は実装されたクラスのメソッドを使いますが、今回はテストコード内で定義したメソッドを検証してみます。
このtestMethodは引数にbool?を受け取ります。
引数の値によってint?を返却するという実装です。


    test('メソッドをテストする', () {
      int? result;

      result = testMethod(flag: true);
      expect(result, 1);

      result = testMethod(flag: false);
      expect(result, 2);

      result = testMethod(flag: null);
      expect(result, isNull);
    });

最初にメソッドの結果を受け取る変数を作ります。
そして引数に3通りの値を入れて実行した結果を変数で受け取り、それぞれを検証しています。

3-4. skipの挙動


  group('sample testその1', skip: 'サンプルのためカバレッジから除外', () {

  // 省略

  });

  group('sample testその2', skip: false, () {
    setUp(() {
      logger.d('二つ目のgroup直下で宣言したsetup');
    });
    // 引数のskipに値を入れるとflutter testコマンドでのtestにスキップされる
    // このフォルダ内でテストの実行はできる
    // カバレッジにまだ含めたくない場合などで使うといい。
    test('スキップされるテスト1', skip: '今回のリリースには関係ないのでスキップ', () {
      expect(someBool, isTrue);
    });
    // falseにするとスキップされない <= 引数自体消す
    test('スキップされるテスト2', skip: true, () {
      expect(someBool, isTrue);
    });
  });
  

group()test()のオプションであるskipについてです。

今回のテストはテストを実行して検証することは可能です。
しかし先にも説明したflutter testコマンドを実行すると検証結果からは除外されます。

スクリーンショット 2024-06-22 22.47.36.png

個人的には文字列でなぜこのテストをスキップするのか理由を書いた方がわかりやすいと思いました。
bool値を使う場合はテストを書いている最中で、一時的にテストをオフにしておきたい時だけかと思いました。

余談:setUp()tearDown()の実行タイミング

スクリーンショット 2024-06-22 22.58.55.png

このテストファイルのmain()関数上のRunを実行してログを確認してみてください。
するとmian()関数直下のsetUp()tearDown()
group()内のsetUp()tearDown()の実行タイミングが把握できると思います。
この挙動を参考に、今後はどこでどの関数を使うべきかを判断するといいでしょう。

ログはデバックコンソールかテスト結果のタブ内で確認できます。

基本方針としては
mian()関数直下にsetUp()tearDown()を置いて、全てのテストケースに適用するのが無難だと思います。

前提条件がテストケースによって違う場合
mian()関数直下には最低限の内容でsetUp()tearDown()を記述
group()ごとにそれぞれ必要な設定をsetUp()tearDown()を記述

などが考えられます。

終わりに

いかがでしたでしょうか。
この記事ではFlutterにおける単体テストの基礎をサンプルを使って解説しました。
テストはエンジニアにとって非常に重要なスキルですので、少しずつ慣れていきたいところです。

正直、実装よりも楽しくない作業となりがちなためあまり気が進まない方は多いと思います。
自分はそうでした🙂
しかし、自身の書いたコードが仕様に沿ったものなのか確認し、コードの品質を担保する作業でもあります。

今回は基礎編ということでしたが、次回は実際の実装を使ってさらに具体的な内容を解説できればと思っています。

この記事がFlutter学習者のお役に立てれば幸いです。

実際の実装を使ってユニットテストを書いた記事

6
2
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
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?