株式会社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
が持つsetInt
とgetInt
が期待通りに呼ばれているか検証します。
テストしにくいコード
テストしにくいコードとは、依存部の切り替えができないコードです。
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
テストしやすいコード
テストしやすいコードとは、依存部の切り替えができるコードです。
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
を自由に制御できます。
このように依存部を外からセットできるよう設計されたパターンを依存性の注入(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
になります。
テストを実施して検証する
テストを実施して、期待通りにsetInt
とgetInt
が呼ばれていることを確認します。
/// テスト実施
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.read
でSharedPreferences
のインスタンスを取得できます。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は比較的少ないのではないでしょうか。
ここで大事なのは、いつでもテストコードが書けるように設計することだと思います。テストを考慮して考えられた設計は、ロジックの責務がはっきりしており、保守しやすいコードになると感じます。
テストコードを書く前提で開発をすれば、必然的に良い設計になり、品質の高いソフトウェア開発ができると信じています。