Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

Android位置情報ライブラリでインターフェースによるテスタビリティ向上を確かめる

エンジニアの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がクラスからインターフェースになったとのことです。

21.0.1のリリースノート

21.0.0のリリースノート

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を使うことで、テスト対象メソッドの結果の制御がしやすくなったりセットアップが単純になったりすることでテストが容易になりました。

参考