Create unit tests using the Health Connect Testing library

The Health Connect Testing library (androidx.health.connect:connect-testing) simplifies the creation of automated tests. You can use this library to verify the behavior of your application and validate that it responds correctly to uncommon cases, which are hard to test manually.

You can use the library to create local unit tests, which typically verify the behavior of the classes in your app that interact with the Health Connect client.

To start using the library, add it as a test dependency:

 testImplementation("androidx.health.connect:connect-testing:1.0.0-alpha01")

The entry point to the library is the FakeHealthConnectClient class, which you use in tests to replace the HealthConnectClient. The FakeHealthConnectClient has the following features:

  • An in-memory representation of records, so you can insert, remove, delete and read them
  • Generation of change tokens and change tracking
  • Pagination for records and changes
  • Aggregation responses are supported with stubs
  • Allows any function to throw exceptions
  • A FakePermissionController that can be used to emulate permissions checks

To learn more about replacing dependencies in tests, read Dependency Injection in Android. To know more about fakes, read Using test doubles in Android.

For example, if the class that interacts with the client is called HealthConnectManager and it takes a HealthConnectClient as a dependency, it would look like:

class HealthConnectManager(
    private val healthConnectClient: HealthConnectClient,
    ...
) { }

In tests, you can pass a fake to your class under test instead:

import androidx.health.connect.client.testing.ExperimentalTestingApi
import androidx.health.connect.client.testing.FakeHealthConnectClient
import kotlinx.coroutines.test.runTest

@OptIn(ExperimentalTestingApi::class)
class HealthConnectManagerTest {

    @Test
    fun readRecords_filterByActivity() = runTest {
        // Create a Fake with 2 running records.
        val fake = FakeHealthConnectClient()
        fake.insertRecords(listOf(fakeRunRecord1, fakeBikeRecord1))

        // Create a manager that depends on the fake.
        val manager = HealthConnectManager(fake)

        // Read running records only.
        val runningRecords = manager.fetchReport(activity = Running)

        // Verify that the records were filtered correctly.
        assertTrue(runningRecords.size == 1)
    }
}

This test verifies that the fictional fetchReport function in HealthConnectManager properly filters records by activity.

Verifying exceptions

Almost every call to HealthConnectClient can throw exceptions. For example, the documentation for insertRecords mentions these exceptions:

  • @throws android.os.RemoteException for any IPC transportation failures.
  • @throws SecurityException for requests with unpermitted access.
  • @throws java.io.IOException for any disk I/O issues.

These exceptions cover cases like a bad connection or no space left on the device. Your app must react correctly to these runtime issues, as they can happen at any time.

import androidx.health.connect.client.testing.stubs.stub

@Test
fun addRecords_throwsRemoteException_errorIsExposed() {
    // Create Fake that throws a RemoteException
    // when insertRecords is called.
    val fake = FakeHealthConnectClient()
    fake.overrides.insertRecords = stub { throw RemoteException() }

    // Create a manager that depends on the fake.
    val manager = HealthConnectManager(fake)

    // Insert a record.
    manager.addRecords(fakeRunRecord1)

    // Verify that the manager is exposing an error.
    assertTrue(manager.errors.size == 1)
}

Aggregation

Aggregation calls don't have fake implementations. Instead, aggregation calls use stubs that you can program to behave in a certain way. You can access the stubs through the overrides property of the FakeHealthConnectClient.

For example, you can program the aggregate function to return a specific result:

import androidx.health.connect.client.testing.AggregationResult
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.ExerciseSessionRecord
import java.time.Duration

@Test
fun aggregate() {
    // Create a fake result.
    val result =
        AggregationResult(metrics =
            buildMap {
                put(HeartRateRecord.BPM_AVG, 74.0)
                put(
                    ExerciseSessionRecord.EXERCISE_DURATION_TOTAL,
                    Duration.ofMinutes(30)
                )
            }
        )

    // Create a fake that always returns the fake
    // result when aggregate() is called.
    val fake = FakeHealthConnectClient()
    fake.overrides.aggregate = stub(result)

Then, you can verify that your class under test, HealthConnectManager in this case, processed the result correctly:

// Create a manager that depends on the fake.
val manager = HealthConnectManager(fake)
// Call the function that in turn calls aggregate on the client.
val report = manager.getHeartRateReport()

// Verify that the manager is exposing an error.
assertThat(report.bpmAverage).isEqualTo(74.0)

Permissions

The testing library includes a FakePermissionController, which can be passed as a dependency to FakeHealthConnectClient.

Your subject under tests can use the PermissionController—through the permissionController property of the HealthConnectClient interface—to check for permissions. This is typically done before every call to the client.

To test this functionality, you can set which permissions are available using the FakePermissionController:

import androidx.health.connect.client.testing.FakePermissionController

@Test
fun newRecords_noPermissions_errorIsExposed() {
    // Create a permission controller with no permissions.
    val permissionController = FakePermissionController(grantAll = false)

    // Create a fake client with the permission controller.
    val fake = FakeHealthConnectClient(permissionController = permissionController)

    // Create a manager that depends on the fake.
    val manager = HealthConnectManager(fake)

    // Call addRecords so that the permission check is made.
    manager.addRecords(fakeRunRecord1)

    // Verify that the manager is exposing an error.
    assertThat(manager.errors).hasSize(1)
}

Pagination

Pagination is a very common source of bugs, so FakeHealthConnectClient provides mechanisms to help you verify that your paging implementation for records and changes behaves correctly.

Your subject under test, HealthConnectManager in our example, can specify the page size in the ReadRecordsRequest:

fun fetchRecordsReport(pageSize: Int = 1000) }
    val pagedRequest =
        ReadRecordsRequest(
            timeRangeFilter = ...,
            recordType = ...,
            pageToken = page1.pageToken,
            pageSize = pageSize,
        )
    val page = client.readRecords(pagedRequest)
    ...

Setting the page size to a small value, such as 2, lets you easily test pagination. For example, you can insert 5 records so that readRecords returns 3 different pages:

@Test
fun readRecords_multiplePages() = runTest {

    // Create a Fake with 2 running records.
    val fake = FakeHealthConnectClient()
    fake.insertRecords(generateRunningRecords(5))

    // Create a manager that depends on the fake.
    val manager = HealthConnectManager(fake)

    // Read records with a page size of 2.
    val report = manager.generateReport(pageSize = 2)

    // Verify that all the pages were processed correctly.
    assertTrue(report.records.size == 5)
}

Test data

The library doesn't include APIs to generate fake data yet, but you can use the data and generators used by the library in Android Code Search.

Stubs

The overrides property of FakeHealthConnectClient lets you program (or stub out) any of its functions so that they throw exceptions when called. Aggregation calls can also return arbitrary data, and it supports enqueuing multiple responses. See Stub and MutableStub for more information.

Summary of edge cases

  • Verify that your app behaves as expected when the client throws exceptions. Check the documentation of each function to figure out what exceptions you should check.
  • Verify that every call you make to the client is preceded by the proper permissions check.
  • Verify your pagination implementation.
  • Verify what happens when you fetch multiple pages but one has an expired token.