43
19

Flutterでテストしやすいコードを書いてみた

Last updated at Posted at 2023-07-14

株式会社Neverのshoheiです。

株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。

TL;DR

  • テストコードを書く目的は、ソフトウェア品質を上げるため(当たり前)
  • テストしやすいコードとは、環境によって依存部の切り替えができるコードのこと
  • テストコードを書く前提で開発をすれば、必然的に良い設計になり、品質の高いソフトウェア開発ができると信じている

概要

社内でテストコードの書き方を説明する機会があったので、いっそのこと記事にまとめました。

本記事では、テストしにくいコードとテストしやすいコードを例にあげ、具体的なテストコードの書き方を解説します。

なお、本記事のテストとは単体テスト(ユニットテスト)です。実機やシミュレータを動かしてテストする統合テストは別記事でまとめます。

そもそもテストコードを書く目的は?

ソフトウェア品質を上げるためです。以下のようなケースで効果を発揮します。

  • 条件分岐が多いロジック
  • 様々な状態を参照・変更するようなロジック
  • 目測だけでは結果がわからないロジック(jsonシリアライズや正規表現など)

そもそも条件分岐や状態変更が多いロジック書くなよというツッコミは置いといて

テストコードを書くことで、機械的にそのロジックが正しいかどうか検証できます。

また、テストコードがあることで安心してコードレビューができます。テストコードを書かせることで、ロジックの誤りをレビュー前に修正させることができ、コスト削減につながります。

テストコードのレビューは増えてしまいますが、どんなテストをしているのかがわかるので、足りないテストがあれば「このシナリオのテストコードも追加して検証してください」と言えるのがとても良いです。

テストコードを書く上で大事なこと

テスト対象のロジックが参照している「依存部」をどうするかです。例えばDBを参照するロジックをテストする場合、依存部は「DBにアクセスできるインスタンス」になります。

この依存部を「本番のインスタンス」にするのか、もしくは「ダミーのインスタンス」にするかでテストコードの書き方が変わります。本番のインスタンスを利用すると、本番のDB環境の構築や、テストシナリオに合う状態を作らないといけないので、準備コストが多くかかり大変です。

そこで、本番環境不要でテストシナリオに合う状態を簡単に作るため「ダミーのインスタンス」を用いる方法があります。この「ダミーのインスタンス」のことをMock(モック)と呼び、Mockを使うことで様々な条件下でのテストができます。

Mockを使うことでのメリットとデメリット

Mockを使うメリットとしては、依存部の状態を自由に作ることができるので、テストしたいロジックを全て網羅することができます。

デメリットとしては、本番のインスタンスで発生する副作用が考慮できないことです。Mockは本番のインスタンスのIFのみ踏襲され、実際の本番の動きまでは踏襲されません。よって、指定するパラメータによっては本番ではエラーが発生する場合があります。

本番のインスタンスの挙動や副作用は既に検証済みであることを前提とした上で、Mockを使ったテストを実施します。

Mockを使ったテストで検証できることは「想定される依存部の条件下で、実装したロジックが全て期待通りの結果になること」です。

テストしやすいコードの解説

前置きが長くなりましたが、Mockを使ったテストコードの解説をします。

今回、テスト対象の例は「ローカルDBにデータを保存・取得するロジック」です。

ローカルDBはSharedPreferencesを使用し、SharedPreferencesが持つsetIntgetIntが期待通りに呼ばれているか検証します。

テストしにくいコード

テストしにくいコードとは、依存部の切り替えができないコードです。

class SharedPreferencesRepository {
  Future<void> saveInt(DatabaseKey key, int value) async {
    final db = await SharedPreferences.getInstance();
    await db.setInt(key.name, value);
  }

  Future<int?> fetchInt(DatabaseKey key) async {
    final db = await SharedPreferences.getInstance();
    return db.getInt(key.name);
  }
}

Mock化したい依存部は SharedPreferences.getInstance()が持つメソッドです。しかし、SharedPreferences.getInstance()はロジック内で生成されているため、Mockをセットすることができません。これではsetIntgetIntが期待通りに呼ばれているか検証するテストコードが書けません。

※SwiftにはSwizzleというインスタンス化されたメソッドを置き換えるという魔法があります。Dartにあるか分かりませんが、あっても使いませんw

テストしやすいコード

テストしやすいコードとは、依存部の切り替えができるコードです。

class SharedPreferencesRepository {
  SharedPreferencesRepository(this._db);

  final SharedPreferences _db;

  Future<void> saveInt(DatabaseKey key, int value) async {
    await _db.setInt(key.name, value);
  }

  Future<int?> fetchInt(DatabaseKey key) async {
    // getIntは同期なので、fetchIntをFuture型にしなくても良いです
    return _db.getInt(key.name);
  }
}

依存部のSharedPreferencesをコンストラクタでセットできるようにした実装です。Mock化したSharedPreferencesを利用できるので、setIntgetIntを自由に制御できます。

このように依存部を外からセットできるよう設計されたパターンを依存性の注入(DI)と呼びます。コンストラクタで依存部をセットしているので、コンストラクタ注入と呼ぶようです。

依存性の注入(いぞんせいのちゅうにゅう、英: Dependency injection)とは、あるオブジェクトや関数が、依存する他のオブジェクトや関数を受け取るデザインパターンである
引用: Wikipedia

テストコードを書いてみる

テストコードを書いてみましょう。MockはMockitoというパッケージを使います。Mockitoの導入方法や使い方はこちらを参照してください。

@GenerateNiceMocks([MockSpec<SharedPreferences>()])
void main() {
  const key = DatabaseKey.counter;

  group('SharedPreferencesRepository テスト', () {
    late final MockSharedPreferences mockSharedPreferences;

    setUpAll(() {
      mockSharedPreferences = MockSharedPreferences();
    });

    tearDown(() {
      // セットされたデータを初期化するためにMockをリセットする
      reset(mockSharedPreferences);
    });

    test(
      '保存と取得ができること',
      () async {
        /// Mockにデータをセットする
        when(
          mockSharedPreferences.setInt(key.name, any),
        ).thenAnswer((_) async => true);
        when(
          mockSharedPreferences.getInt(key.name),
        ).thenAnswer((_) => 0);

        /// RepositoryにMockをセットし、テスト対象のインスタンスを生成
        final repository = SharedPreferencesRepository(mockSharedPreferences);

        /// テスト実施
        await repository.saveInt(key, 0);
        final result = await repository.fetchInt(key);

        /// テスト結果を検証
        expect(result, 0);
        verify(mockSharedPreferences.setInt(key.name, 0)).called(1);
        verify(mockSharedPreferences.getInt(key.name)).called(1);
      },
    );    
  });
}

Mockにデータをセットし、RepositoryにMockをセットする

Mockにデータをセットします。

const key = DatabaseKey.counter;

...
late final MockSharedPreferences mockSharedPreferences;

setUpAll(() {
 mockSharedPreferences = MockSharedPreferences();
});

tearDown(() {
 // セットされたデータを初期化するためにMockをリセットする
 reset(mockSharedPreferences);
});
...

/// Mockにデータをセットする
when(
  mockSharedPreferences.setInt(key.name, any),
).thenAnswer((_) async => true);
when(
  mockSharedPreferences.getInt(key.name),
).thenAnswer((_) => 0);

SharedPreferencesをMock化したMockSharedPreferencesのメソッドにダミーデータをセットします。setIntに期待するパラメータが指定されたらtrueを返すようにセットし、getIntの場合は0を返すようにします。

次に、RepositoryにMockをセットし、テスト対象のSharedPreferencesRepositoryインスタンスを生成します。

final repository = SharedPreferencesRepository(mockSharedPreferences);

これにより、SharedPreferencesRepositoryが持つSharedPreferences _db;mockSharedPreferencesになります。

テストを実施して検証する

テストを実施して、期待通りにsetIntgetIntが呼ばれていることを確認します。

/// テスト実施
await repository.saveInt(key, 0);
final result = await repository.fetchInt(key);

/// テスト結果を検証
expect(result, 0);
verify(mockSharedPreferences.setInt(key.name, 0)).called(1);
verify(mockSharedPreferences.getInt(key.name)).called(1);

expectを利用することで、実施結果が期待通りであるか検証できます。

verifyで、期待通りのパラメータ指定でメソッドが呼ばれているか検証できます。called(1)にしているので、そのメソッドが指定されたパラメータで1回呼ばれていることが検証できます。

これで対象のロジックが正しい動きをするかテストコードで検証できるようになりました。

サンプルコードがただのラッパーなので、テストの効果があまり感じられなかったかもしれません。ただ冒頭でもお伝えした通り、条件式が多いロジックや様々な状態を参照・変更するようなロジックでは効果が発揮されます。

「このロジックIF文が多いなぁ...」「色んなインスタンスのメソッド呼んでるし内部で状態変更してるなぁ..」っといったロジックがあれば是非テストコードを書いて検証してみてはいかがでしょうか。

Service Locatorを使う

Service Locatorを使ったテストコードの書き方を解説します。

Service Locatorとはざっくり言うと、インスタンスを管理する役割を担い、インスタンスのセットと取得が容易にできる手段です。Service Locatorの解説はこちらの記事が参考になりますのでお読みください。

Riverpodを使ったテストコード

Riverpodは状態管理パッケージの一つで、インスタンスのセットや取得を容易にできる機能があります。Providerが持つ機能でインスタンスをセットでき、Refを使うことでセットされたインスタンスを取得できます。

class SharedPreferencesRepository {
  SharedPreferencesRepository(this._ref);

  final Ref _ref;

  Future<void> saveInt(DatabaseKey key, int value) async {
    final db = _ref.read(sharedPreferencesProvider);
    await db.setInt(key.name, value);
  }

  Future<int?> fetchInt(DatabaseKey key) async {
    final db = _ref.read(sharedPreferencesProvider);
    return db.getInt(key.name);
  }
}

_ref.readSharedPreferencesのインスタンスを取得できます。sharedPreferencesProviderにMockインスタンスをセットすることでテストができます。

final sharedPreferencesProvider = Provider<SharedPreferences>(
  (_) => throw UnimplementedError(),
);

sharedPreferencesProviderは何もセットされていない場合はUnimplementedErrorを発生させています。本番のSharedPreferencesインスタンス取得が非同期であることから、外でのインスタンス生成を強制させるためです。

Riverpodを用いたテストコードは以下のようになります。

    test(
      '[riverpod] 保存と取得ができること',
      () async {
        /// Mockにデータをセットする
        when(
          mockSharedPreferences.setInt(key.name, any),
        ).thenAnswer((_) async => true);
        when(
          mockSharedPreferences.getInt(key.name),
        ).thenAnswer((_) => 0);

        /// ProviderにMockをセットする
        final container = ProviderContainer(
          overrides: [
            sharedPreferencesProvider.overrideWithValue(mockSharedPreferences)
          ],
        );
        addTearDown(container.dispose);

        /// テスト対象のインスタンスを生成
        final repository =
            container.read(sharedPreferencesRepositoryProvider);

        /// テスト実施
        await repository.saveInt(key, 0);
        final result = await repository.fetchInt(key);

        /// テスト結果を検証
        expect(result, 0);
        verify(mockSharedPreferences.setInt(key.name, 0)).called(1);
        verify(mockSharedPreferences.getInt(key.name)).called(1);
      },
    );

ProviderにMockをセットするテスト対象のインスタンスを生成以外は前回と同じです。

sharedPreferencesProvider.overrideWithValueを用いてmockSharedPreferencesをセットします。

final container = ProviderContainer(
  overrides: [
    sharedPreferencesProvider.overrideWithValue(mockSharedPreferences)
  ],
);
addTearDown(container.dispose);

container.read(sharedPreferencesRepositoryProvider)経由でSharedPreferencesRepositoryを取得します。

final sharedPreferencesRepositoryProvider =
    Provider<SharedPreferencesRepository>(SharedPreferencesRepository.new);

...

final repository = container.read(sharedPreferencesRepositoryProvider);
await repository.saveInt(key, 0);
final result = await repository.fetchInt(key);

取得したSharedPreferencesRepositoryをテストします。これによりSharedPreferencesRepositoryロジック内では、_ref.readで取得されるインスタンスは全てMockになり、期待通りのテストができます。

GetItを使ったテストコード

GetItとは、Service Locatorパッケージです。軽量なパッケージであるため、Flutterの様々な状態管理法とセットで利用することができます。

final getIt = GetIt.instance;

void main() async {
  final sharedPreferences = await SharedPreferences.getInstance();
  getIt.registerLazySingleton<SharedPreferences>(
      () => sharedPreferences,
    );
  ...
}

...

class SharedPreferencesRepository {
  Future<void> saveInt(DatabaseKey key, int value) async {
    final db = getIt<SharedPreferences>();
    await db.setInt(key.name, value);
  }

  Future<int?> fetchInt(DatabaseKey key) async {
    final db = getIt<SharedPreferences>();
    return db.getInt(key.name);
  }
}

事前にGetItのregisterメソッドでインスタンスをセットし、getIt<T>()のTに取得したい型を指定すると、そのインスタンスが取得できます。

GetItを用いたテストコードは以下のようになります。

    test(
      '[get_it] 保存と取得ができること',
      () async {
        /// Mockにデータをセットする
        when(
          mockSharedPreferences.setInt(key.name, any),
        ).thenAnswer((_) async => true);
        when(
          mockSharedPreferences.getInt(key.name),
        ).thenAnswer((_) => 0);

        /// GetItにMockをセットする
        getIt.registerFactory<SharedPreferences>(
          () => mockSharedPreferences,
        );
        addTearDown(getIt.reset);

        /// テスト対象のインスタンスを生成
        final repository = SharedPreferencesRepository();

        /// テスト実施
        await repository.saveInt(key, 0);
        final result = await repository.fetchInt(key);

        /// テスト結果を検証
        expect(result, 0);
        verify(mockSharedPreferences.setInt(key.name, 0)).called(1);
        verify(mockSharedPreferences.getInt(key.name)).called(1);
      },
    );

GetItにMockをセットするテスト対象のインスタンスを生成以外は前回と同じです。

getIt.registerFactoryを用いてMockをセットします。

getIt.registerFactory<SharedPreferences>(
  () => mockSharedPreferences,
);
addTearDown(getIt.reset);

あとは、SharedPreferencesRepositoryを生成してテストします。

final repository = SharedPreferencesRepository();
await repository.saveInt(key, 0);
final result = await repository.fetchInt(key);

これによりSharedPreferencesRepositoryロジック内では、getIt<SharedPreferences>()で取得されるインスタンスは全てMockになり、期待通りのテストができます。

まとめ

テストしやすいコードとは、依存部の切り替えができるコードです。本番環境やテスト環境に応じて、依存部に本番のDBを参照するインスタンスや、ダミーデータを返すMockをセットできる実装が好ましいです。

今回のテストコードはこちらのリポジトリにまとめています。本記事で解説できなかった内容もあるので、そこは別途記事にしたいと思います。

最後に

現実的なお話として、モバイルアプリ開発でテストコード書こうとするとそれなりに工数が必要です。

テストコードを書く工数もそうですが、それをメンテナンスする工数も必要になります。実際のデバイスでテストして品質を担保する工程があるため、予算の都合上、初期段階からテストコードを書くPJは比較的少ないのではないでしょうか。

ここで大事なのは、いつでもテストコードが書けるように設計することだと思います。テストを考慮して考えられた設計は、ロジックの責務がはっきりしており、保守しやすいコードになると感じます。

テストコードを書く前提で開発をすれば、必然的に良い設計になり、品質の高いソフトウェア開発ができると信じています。

43
19
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
43
19