エンジニアのid:toricorです。今年の初めまではサーバサイド(Perl)のタスクを中心に仕事をしていましたが、その後Android & iOS開発を担当するようになりもうすぐ1年になります。
今日はAndroidの位置情報ライブラリを題材に、インターフェースを活用してテスト用に位置情報のデータソースを差し替えやすくするAndroidのテスト例を紹介します。
play-services-location
の21系ではFusedLocationProviderClientがクラスからインターフェースに変わった
位置情報取得の中心を担うライブラリplay-services-location
の最新のリリースのうち、今回はFusedLocationProviderClientの変更に焦点をあてます。
アプリケーション開発者はFusedLocationProviderClientを介して位置情報を利用します。FusedLocationProviderClientは、Android端末がGPSやWifiなどから取得した位置情報について、まとめて管理して適切な位置情報を返してくれます。
さて、2022年10~11月リリースのplay-services-location
の21系のリリースノートによるとFusedLocationProviderClientがクラスからインターフェースになったとのことです。
FusedLocationProviderClient, ActivityRecognitionClient, GeofencingClient and SettingsClient are now interfaces instead of classes, which helps enforce correct usage and improves testability. developers.google.com
インターフェースになったことで improves testability
テスタビリティ(テスト容易性)が向上したということです。
Androidのテストではインターフェースが共通のテスト用の偽の実装と差し替えるパターンが推奨されていますが、実際にテストが容易になったのかをテストを書き実感したいと思います。
現在地を取得するgetCurrentLocationメソッドが正しい位置オブジェクトを返すかテストしたい
単純な現在地取得実装を用意しました
- GeoLocationRepository内でFusedLocationProviderClientの現在地取得(getCurrentLocation)メソッドを呼び出します
- GeoLocationRepositoryのコンストラクタはFusedLocationProviderClientを受け取り差し替え可能にします
- getCurrentLocationが成功すればaddOnSuccessListener、失敗すればaddOnFailureListenerで追加されたリスナーが呼ばれます
// 一部省略 class GeoLocationRepository(private val locationProvider: FusedLocationProviderClient) { private val currentLocationRequest = CurrentLocationRequest.Builder().apply { setPriority(Priority.PRIORITY_HIGH_ACCURACY) setDurationMillis(1000L) setMaxUpdateAgeMillis(30000L) }.build() // テスト対象のメソッド // 取得したLocationの各値を、自前で用意したLocationPayloadに詰め替える @SuppressLint("MissingPermission") suspend fun getCurrentLocation(): LocationPayload { val def = CompletableDeferred<LocationPayload>() val cancellationTokenSource = CancellationTokenSource() val locationTask: Task<Location> = locationProvider.getCurrentLocation( currentLocationRequest, cancellationTokenSource.token ) locationTask.addOnSuccessListener { location: Location? -> def.complete( if (location == null) { getEmptyLocationPayload() } else { buildLocationPayload(location) } ) } locationTask.addOnFailureListener { Log.d("GeoLocationRepository", "FailureListener @@@@@@") def.complete(getEmptyLocationPayload()) } return try { def.await() } finally { cancellationTokenSource.cancel() } }
本物のFusedLocationProviderClientを使うテストはセットアップと結果の制御が難しい
まず本物のFusedLocationProviderClientクラスをそのまま使うテストを考えます。
FusedLocationProviderClientにはmockモードがあり、任意の位置情報を返すようにセットすることができます。 getCurrentLocationを呼び出したときにセットしておいた位置情報が取れたかどうかを確かめるテストを書けます。
しかし事前準備は少々手間がかかります。
debug/AndroidManifest.xmlに
<uses-permission android:name="android.permission.ACCESS_MOCK_LOCATION" tools:ignore="ProtectedPermissions" />
を与えます「設定」->「開発者向けオプション」-> 「仮の現在地情報アプリを選択(Select mock location app)」から対象アプリを指定しておきます
(またはUiautomatorを利用しadb shell appops
を使う方法があります )
// setMockMode=trueの場合のテスト例 class GeoLocationRepositoryTest { private lateinit var client: FusedLocationProviderClient private val location = Location("mock").apply { latitude = 35.6812362 longitude = 139.7671248 speed = 42.0F accuracy = 0.68f time = System.currentTimeMillis() elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() } @Before fun setUp() { val context = InstrumentationRegistry.getInstrumentation().targetContext client = LocationServices.getFusedLocationProviderClient(context) client.setMockMode(true).addOnFailureListener { throw it } } @After fun tearDown() { client.setMockMode(false).addOnFailureListener { throw it } } @Test fun latitudeIsCorrect() { client.setMockLocation(location).addOnFailureListener { throw it } runTest { val acquiredLocation = GeoLocationRepository(client).getCurrentLocation() assertEquals(35.6812, acquiredLocation.latitude, 0.001) } } }
- 本物のFusedLocationProviderClientが提供するsetMockModeをtrueにすることで、getCurrentLocationが成功した場合の本物のレスポンスに近しいテストが可能です
- getCurrentLocationを意図的に失敗させ任意の例外を発生させるようなテストはできません
FakeのFusedLocationProviderClientを使う場合はセットアップと返り値の改変が容易になる
play-services-location
の21系ではFusedLocationProviderClientがインターフェースとなりました。
この結果、本物のFusedLocationProviderClientの代わりに、FusedLocationProviderClientインターフェースを実装する偽のFakeFusedLocationProviderClientをGeoLocationRepositoryに渡すことができるようになりました。
// 偽のFusedLocationProviderClient // 一部省略 class FakeFusedLocationProviderClient : FusedLocationProviderClient { // テストケースごとに返り値を変化させるためのフラグ var shouldFail = false private val location = Location("mock").apply { latitude = 35.6812362 longitude = 139.7671248 speed = 42.0F accuracy = 0.68f time = System.currentTimeMillis() elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() } override fun getCurrentLocation( p0: CurrentLocationRequest, p1: CancellationToken? ): Task<Location> { // https://developers.google.com/android/reference/com/google/android/gms/tasks/Tasks return if (shouldFail) { Tasks.forException(Exception()) } else { Tasks.forResult(location) } } // インターフェースのメンバーを省略 override fun setMockMode(p0: Boolean): Task<Void> { TODO("Not yet implemented") } } @OptIn(ExperimentalCoroutinesApi::class) class GeoLocationRepositoryWithFakeClientTest { private lateinit var fakeClient: FakeFusedLocationProviderClient @Before fun setupClient() { fakeClient = FakeFusedLocationProviderClient() } @After fun tearDown() { fakeClient.shouldFail = false } @Test fun latitudeIsCorrect() { runTest { // FakeのClientを渡す! val acquiredLocation = GeoLocationRepository(fakeClient).getCurrentLocation() assertEquals(35.6812362, acquiredLocation.latitude, 0.0001) } } @Test fun zeroLatitudeIsAcquiredWhenFail() { runTest { fakeClient.shouldFail = true val acquiredLocation = GeoLocationRepository(fakeClient).getCurrentLocation() assertEquals(0.0, acquiredLocation.latitude, 0.0001) } } }
ここまでで、次の2点でテスタビリティ向上を確認できました。
- テスト用の偽のFusedLocationProviderClientに置き換えることで、getCurrentLocationの返り値を自由に変更できるようになりました
- getCurrentLocation失敗時のFailureリスナーを呼び出しやすくなりました
- ACCESS_MOCK_LOCATION権限付与は不要になりセットアップが簡単になりました
まとめ
play-services-location
の21系を使うテストでは、本物ではなく偽のFusedLocationProviderClientを使うことで、テスト対象メソッドの結果の制御がしやすくなったりセットアップが単純になったりすることでテストが容易になりました。