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

[株式会社Never](https://neverjp.com/)のshoheiです。

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

https://neverjp.com/service/

# TL;DR

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

# 概要

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ローカルDBは[SharedPreferences](https://pub.dev/packages/shared_preferences)を使用し、`SharedPreferences`が持つ`setInt`と`getInt`が期待通りに呼ばれているか検証します。

## テストしにくいコード

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

```dart
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をセットすることができません。これでは`setInt`と`getInt`が期待通りに呼ばれているか検証するテストコードが書けません。

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

## テストしやすいコード

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

```dart
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`を利用できるので、`setInt`や`getInt`を自由に制御できます。

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

>依存性の注入（いぞんせいのちゅうにゅう、英: Dependency injection）とは、あるオブジェクトや関数が、依存する他のオブジェクトや関数を受け取るデザインパターンである
>引用: [Wikipedia](https://ja.wikipedia.org/wiki/%E4%BE%9D%E5%AD%98%E6%80%A7%E3%81%AE%E6%B3%A8%E5%85%A5)


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

テストコードを書いてみましょう。Mockは[Mockito](https://pub.dev/packages/mockito)というパッケージを使います。Mockitoの導入方法や使い方は[こちら](https://docs.flutter.dev/cookbook/testing/unit/mocking)を参照してください。

```dart
@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にデータをセットします。

```dart
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`インスタンスを生成します。

```dart
final repository = SharedPreferencesRepository(mockSharedPreferences);
```

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

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

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

```dart
/// テスト実施
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の解説は[こちらの記事](https://www.nuits.jp/entry/servicelocator-vs-dependencyinjection)が参考になりますのでお読みください。

## Riverpodを使ったテストコード

[Riverpod](https://docs-v2.riverpod.dev/)は状態管理パッケージの一つで、インスタンスのセットや取得を容易にできる機能があります。`Provider`が持つ機能でインスタンスをセットでき、`Ref`を使うことでセットされたインスタンスを取得できます。

```dart
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.read`で`SharedPreferences`のインスタンスを取得できます。`sharedPreferencesProvider`にMockインスタンスをセットすることでテストができます。

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

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


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

```dart
    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`をセットします。

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

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

```dart
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](https://pub.dev/packages/get_it)とは、Service Locatorパッケージです。軽量なパッケージであるため、Flutterの様々な状態管理法とセットで利用することができます。

```dart
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を用いたテストコードは以下のようになります。

```dart
    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をセットします。

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

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

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

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

# まとめ

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

今回のテストコードはこちらの[リポジトリ](https://github.com/never-inc/flutter_experiment)にまとめています。本記事で解説できなかった内容もあるので、そこは別途記事にしたいと思います。


# 最後に

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

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

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

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