Michael Evans

A bunch of technobabble.

UI Testing Made Easy: The Robot Test Pattern on Android

| Comments

As Android applications grow in complexity, maintaining a robust testing strategy becomes increasingly challenging. The “Robot Testing Pattern” offers a structured approach to UI testing that can significantly improve your test suite’s maintainability and reliability. This guide is designed for Android developers who have basic experience with testing and want to enhance their testing methodology.

Understanding the Robot Pattern

The Robot Testing Pattern, also known as the Robot Framework or Robot Pattern, is a testing methodology that creates an abstraction layer between your test code and UI interactions. Think of robots as specialized assistants that handle all the UI interactions on behalf of your tests.

Key Benefits

The Robot Pattern provides several advantages that make it particularly valuable for Android testing:

  1. Improved Test Readability: Tests become high-level descriptions of user behavior rather than low-level UI interactions, making them easier to understand and maintain.

  2. Enhanced Maintainability: When UI changes occur, updates are needed only in the robot implementation rather than across multiple test files.

  3. Code Reusability: Common interactions can be shared across multiple test cases, reducing duplication and ensuring consistency.

  4. Better Test Organization: The pattern enforces a clear separation between test logic and UI interaction code.

  5. Simplified Parallel Testing: Isolated UI interaction logic enables efficient parallel test execution and better test stability.

Implementation Guide

Let’s explore how to implement the Robot Pattern in both Jetpack Compose and traditional View-based applications.

Jetpack Compose Implementation

Here’s a basic implementation for a login screen using Compose:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class LoginRobot {
    private val composeTestRule = createComposeRule()
    
    fun enterUsername(username: String) = apply {
        composeTestRule.onNodeWithContentDescription("UsernameTextField")
            .performTextInput(username)
    }
    
    fun enterPassword(password: String) = apply {
        composeTestRule.onNodeWithContentDescription("PasswordTextField")
            .performTextInput(password)
    }
    
    fun clickLoginButton() = apply {
        composeTestRule.onNodeWithContentDescription("LoginButton")
            .performClick()
    }
    
    // Add verification methods
    fun verifyErrorMessage(message: String) = apply {
        composeTestRule.onNodeWithText(message)
            .assertIsDisplayed()
    }
}

View-Based Implementation

For applications using traditional Views, the robot implementation looks slightly different:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class LoginRobot {
    fun enterUsername(username: String) = apply {
        onView(withId(R.id.editTextUsername))
            .perform(typeText(username))
    }
    
    fun enterPassword(password: String) = apply {
        onView(withId(R.id.editTextPassword))
            .perform(typeText(password))
    }
    
    fun clickLoginButton() = apply {
        onView(withId(R.id.buttonLogin))
            .perform(click())
    }
    
    // Add verification methods
    fun verifyErrorMessage(message: String) = apply {
        onView(withId(R.id.textViewError))
            .check(matches(withText(message)))
    }
}

Writing Tests with Robots

With these robot implementations, your tests become much more readable and maintainable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
fun loginWithValidCredentials() {
    val loginRobot = LoginRobot()
    
    composeTestRule.setContent {
        LoginScreen()
    }
    
    loginRobot
        .enterUsername("user@example.com")
        .enterPassword("password123")
        .clickLoginButton()
        .verifyLoginSuccess()
}

@Test
fun loginWithInvalidCredentials() {
    val loginRobot = LoginRobot()
    
    composeTestRule.setContent {
        LoginScreen()
    }
    
    loginRobot
        .enterUsername("invalid@example.com")
        .enterPassword("wrongpassword")
        .clickLoginButton()
        .verifyErrorMessage("Invalid credentials")
}

Creating a Base Robot

To promote code reuse and establish common testing patterns, create a base robot class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
abstract class BaseRobot {
    protected val composeTestRule = createComposeRule()
    
    protected fun waitForElement(
        matcher: SemanticsMatcher,
        timeoutMs: Long = 5000
    ) {
        composeTestRule.waitUntil(timeoutMs) {
            composeTestRule
                .onAllNodes(matcher)
                .fetchSemanticsNodes().isNotEmpty()
        }
    }
    
    protected fun assertIsDisplayed(matcher: SemanticsMatcher) {
        composeTestRule.onNode(matcher).assertIsDisplayed()
    }
    
    protected fun assertTextEquals(matcher: SemanticsMatcher, text: String) {
        composeTestRule.onNode(matcher)
            .assertTextEquals(text)
    }
}

Best Practices

  1. Keep Robots Focused: Each robot should represent a specific screen or component. Avoid creating “super robots” that handle multiple screens.

  2. Include Verification Methods: Add methods to verify the state of the UI after actions are performed.

  3. Use Method Chaining: Return the robot instance from each method to enable fluent method chaining.

  4. Handle Async Operations: Include proper wait mechanisms for loading states and animations.

  5. Document Robot Methods: Provide clear documentation for each robot method to explain its purpose and expected behavior.

The Robot Testing Pattern significantly improves the maintainability and reliability of Android UI tests. By separating UI interaction logic into dedicated robot classes, you create more organized, readable, and maintainable tests. When UI changes occur, updates are localized to the robot implementations rather than scattered across test files. Happy coding!

Modernizing Your Android App’s Data Storage: SharedPreferences to DataStore

| Comments

SharedPreferences has long been a staple for storing small pieces of data and user preferences in Android apps. However, it has notable limitations, such as a lack of type safety, no support for safe schema evolution, and potential performance issues on the main thread. Google introduced Proto DataStore as a modern and robust alternative, offering:

  • Strong typing with Protocol Buffers
  • Safe schema evolution
  • Built-in migration support
  • Flow API for reactive programming
  • Coroutines support for main-thread safety

In this post, we’ll walk through the process of migrating your existing SharedPreferences data to Proto DataStore without data loss, including best practices and common pitfalls to avoid.

Step 1: Add Dependencies

First, add the necessary dependencies to your app’s build.gradle file:

1
2
3
4
5
6
7
8
9
dependencies {
    def datastore_version = "1.0.0"
    
    // Proto DataStore
    implementation "androidx.datastore:datastore:$datastore_version"
    
    // Protocol Buffers
    implementation "com.google.protobuf:protobuf-javalite:3.18.0"
}

Step 2: Define Your Proto DataStore Schema

Create a new .proto file in app/src/main/proto/my_data.proto to define your data schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
syntax = "proto3";

option java_package = "com.example.app";
option java_multiple_files = true;

message UserPreferences {
    // Define your fields with unique numbers
    string user_name = 1;
    bool notifications_enabled = 2;
    string theme = 3;
    
    // Optional: Add a version field for future schema evolution
    int32 schema_version = 999;
}

Note the schema_version field – this helps manage schema evolution as your app grows.

Step 3: Create a Proto DataStore Serializer

The serializer handles reading and writing your protocol buffer messages:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object UserPreferencesSerializer : Serializer<UserPreferences> {
    override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
    
    override suspend fun readFrom(input: InputStream): UserPreferences {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }
    
    override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
        t.writeTo(output)
    }
}

Step 4: Create a Repository Class

Following best practices, wrap the DataStore in a repository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class UserPreferencesRepository(private val context: Context) {
    private val dataStore: DataStore<UserPreferences> = context.createDataStore(
        fileName = "user_prefs.pb",
        serializer = UserPreferencesSerializer
    )
    
    val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
        .catch { exception ->
            if (exception is IOException) {
                emit(UserPreferences.getDefaultInstance())
            } else {
                throw exception
            }
        }
    
    suspend fun migrateFromSharedPreferences() = withContext(Dispatchers.IO) {
        try {
            val sharedPrefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
            
            // Read existing preferences
            val userName = sharedPrefs.getString("user_name", "") ?: ""
            val notificationsEnabled = sharedPrefs.getBoolean("notifications_enabled", true)
            val theme = sharedPrefs.getString("theme", "system") ?: "system"
            
            // Migrate to DataStore
            dataStore.updateData { preferences ->
                preferences.toBuilder()
                    .setUserName(userName)
                    .setNotificationsEnabled(notificationsEnabled)
                    .setTheme(theme)
                    .setSchemaVersion(1)
                    .build()
            }
            
            // Verify migration
            val migratedData = dataStore.data.first()
            if (migratedData.userName == userName &&
                migratedData.notificationsEnabled == notificationsEnabled &&
                migratedData.theme == theme) {
                // Clear SharedPreferences after successful migration
                sharedPrefs.edit().clear().apply()
                Result.success(Unit)
            } else {
                Result.failure(Exception("Migration verification failed"))
            }
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun updateUserName(name: String) {
        dataStore.updateData { preferences ->
            preferences.toBuilder()
                .setUserName(name)
                .build()
        }
    }
    
    suspend fun updateNotificationsEnabled(enabled: Boolean) {
        dataStore.updateData { preferences ->
            preferences.toBuilder()
                .setNotificationsEnabled(enabled)
                .build()
        }
    }
    
    suspend fun updateTheme(theme: String) {
        dataStore.updateData { preferences ->
            preferences.toBuilder()
                .setTheme(theme)
                .build()
        }
    }
}

Step 5: Use in Your App

Here’s how to use the repository in your app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MainActivity : AppCompatActivity() {
    private lateinit var repository: UserPreferencesRepository
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        repository = UserPreferencesRepository(applicationContext)
        
        // Migrate data on app first launch
        lifecycleScope.launch {
            repository.migrateFromSharedPreferences()
        }
        
        // Observe preferences changes
        lifecycleScope.launch {
            repository.userPreferencesFlow.collect { preferences ->
                // Update UI based on preferences
                updateUI(preferences)
            }
        }
    }
    
    private fun updateUI(preferences: UserPreferences) {
        // Update your UI components based on preferences
    }
}

Testing Your Migration

Here’s an example of how to test your migration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
fun testMigrationFromSharedPreferences() = runTest {
    val context = ApplicationProvider.getApplicationContext<Context>()
    
    // Set up test SharedPreferences
    val sharedPrefs = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE)
    sharedPrefs.edit()
        .putString("user_name", "Test User")
        .putBoolean("notifications_enabled", true)
        .putString("theme", "dark")
        .apply()
    
    // Perform migration
    val repository = UserPreferencesRepository(context)
    val result = repository.migrateFromSharedPreferences()
    
    // Verify migration
    assert(result.isSuccess)
    
    val migratedData = repository.userPreferencesFlow.first()
    assertEquals("Test User", migratedData.userName)
    assertTrue(migratedData.notificationsEnabled)
    assertEquals("dark", migratedData.theme)
    
    // Verify SharedPreferences was cleared
    assertTrue(sharedPrefs.all.isEmpty())
}

Best Practices and Common Pitfalls

  1. Schema Evolution: When adding new fields to your proto schema, always:

    • Use new field numbers
    • Provide default values
    • Increment the schema version
  2. Error Handling: Always handle potential exceptions when reading/writing data:

    • IOException for file system issues
    • CorruptionException for invalid data
    • InvalidProtocolBufferException for proto parsing errors
  3. Performance:

    • Perform migrations in the background using Coroutines
    • Use Flow’s collectLatest when frequent updates aren’t necessary
    • Consider caching frequently accessed values
  4. Testing:

    • Write unit tests for your repository
    • Test migration with various SharedPreferences states
    • Include error cases in your tests
    • Test schema evolution scenarios

Conclusion

Migrating from SharedPreferences to Proto DataStore requires some initial setup, but the benefits of type safety, schema evolution, and reactive programming make it worthwhile. The resulting code will be more maintainable, type-safe, and resistant to runtime errors. Happy coding!

Mastering Ktlint: A Guide to Crafting Your Own Rules

| Comments

Ktlint is a powerful linting tool for Kotlin code that helps maintain code quality and consistency. While it comes with a set of built-in rules, there may be cases where you want to create custom rules tailored to your project’s specific requirements. In this tutorial, we’ll walk you through the process of writing a custom ktlint rule that detects and removes Android Log statements from your Kotlin code.


Prerequisites

Before we dive into writing custom ktlint rules, ensure you have ktlint installed in your project. The tasks ktlintApplyToIdea and addKtlintCheckTask are provided by the ktlint Gradle plugin. If you haven’t already, include the plugin in your project by adding the following to your build.gradle.kts file:

1
2
3
plugins {
    id("org.jlleitschuh.gradle.ktlint") version "<latest-version>"
}

Once the plugin is applied, run the following command to set up ktlint in your project:

1
./gradlew ktlintApplyToIdea addKtlintCheckTask

Writing a Custom ktlint Rule

1. Create a New Module

To write a custom ktlint rule, start by creating a new module in your Kotlin project.

2. Set Up Your Project

In your new module, make sure you have ktlint as a dependency. Add it to your build.gradle.kts or build.gradle file:

1
2
3
dependencies {
    ktlint("io.gitlab.arturbosch.detekt:detekt-formatting:<ktlint-version>")
}

3. Define the ktlint Rule

Now, let’s define our custom rule. Create a Kotlin class that extends the Rule class and override the visit method to define the logic for your rule. In this example, we want to detect Android Log statements.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import io.gitlab.arturbosch.detekt.api.Rule
import org.jetbrains.kotlin.psi.KtCallExpression

class LogStatementRule : Rule() {
    override fun visitCallExpression(expression: KtCallExpression) {
        if (expression.calleeExpression?.text == "Log" &&
            expression.valueArguments.size == 1
        ) {
            // Report a finding
            report(
                finding = "Found Android Log statement",
                documentable = expression
            )
        }
    }
}

4. Create a Test

As with any code we write, it’s great practice to write tests for your custom rule to ensure it behaves as expected. Use the ktlint testing library to create a test case that demonstrates the rule’s functionality.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import io.gitlab.arturbosch.detekt.test.lint
import org.spekframework.spek2.Spek
import org.spekframework.spek2.style.specification.describe

object LogStatementRuleSpec : Spek({
    describe("a LogStatementRule") {
        val subject = LogStatementRule()

        it("should report Android Log statements") {
            val code = """
                import android.util.Log
                fun example() {
                    Log.d("Tag", "Message")
                }
            """.trimIndent()

            subject.lint(code)
                .shouldContainOnly(subject.findings.single())
        }

        it("should not report other function calls") {
            val code = """
                fun example() {
                    someFunction()
                }
            """.trimIndent()

            subject.lint(code)
                .shouldBeEmpty()
        }
    }
})

5. Add the Rule to ktlint Configuration

To make your custom rule part of your ktlint configuration, add it to the .editorconfig file in your project:

1
2
3
# .editorconfig
# Custom ktlint rules
ktlint.ruleset = LogStatementRule

6. Run ktlint

Run ktlint in your project to check for Android Log statements and automatically remove them using the --apply-to-idea flag:

1
./gradlew ktlintApplyToIdea

Conclusion

In this tutorial, we’ve shown you how to write a custom ktlint rule to detect and remove Android Log statements from your Kotlin code. Custom ktlint rules can help you maintain code quality and consistency by enforcing project-specific coding standards. This is obviously a simple example, but this is a good example to adapt for other custom rules that fit your project’s needs.

Ktlint is a powerful tool that, when combined with custom rules, can significantly improve your Kotlin codebase’s quality and readability. Happy coding!

Stop Repeating Yourself 2: Using Test Fixtures With AGP

| Comments

Modern Android development often involves modularization to enhance build performance and maintainability. As you break down your app into multiple modules, sharing test utilities such as fixtures, mocks, or fakes between modules can become challenging. A few years ago I wrote about the concept of test fixtures, but at the time they weren’t supported for Android modules. Thankfully, with Android Gradle Plugin (AGP) 8.5.0, Google introduced native support for test fixtures, streamlining this process significantly.

What Are Test Fixtures?

Test fixtures are reusable components such as test data, helper classes, or mocks that you can use to support your tests. Prior to AGP 8.5.0, sharing these utilities across modules often required workarounds like creating dedicated “test” modules or manually wiring dependencies. With AGP 8.5.0, the testFixtures feature makes it easier to declare and consume test fixtures directly from your modules.

Setting Up Test Fixtures

To enable test fixtures for a module, you need to include the testFixtures feature in your module’s build.gradle.kts file. Here’s how you can set it up:

1
2
3
4
5
6
7
8
9
android {
    // Enable test fixtures for the module
    testFixtures.enable = true
}

dependencies {
    // Declare dependencies for your test fixtures
    testFixturesImplementation("com.example:some-library:1.0.0")
}

The testFixtures source set is automatically created under the src directory:

1
2
3
4
5
6
7
8
9
module-name/
  src/
    main/
      java/
    testFixtures/
      java/
      kotlin/
    test/
      java/

You can now place reusable test utilities, such as mock data generators or fake implementations, inside the testFixtures directory.

Consuming Test Fixtures

Modules can consume test fixtures by declaring a dependency on the testFixtures configuration of another module. For example, if module-b needs to use the test fixtures from module-a, add the following dependency:

1
2
3
dependencies {
    testImplementation(testFixtures(project(":module-a")))
}

Once this dependency is added, the test code in module-b can seamlessly access the fixtures provided by module-a.

Example: Sharing a Fake API Client

Suppose you have a fake API client used in multiple test cases. You can define it in the testFixtures source set of module-a:

1
2
3
4
5
6
// module-a/src/testFixtures/kotlin/com/example/api/FakeApiClient.kt
package com.example.api

class FakeApiClient {
    fun getFakeData(): String = "Fake Data"
}

Then, in module-b, you can consume and use this fake client:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// module-b/src/test/kotlin/com/example/test/ApiClientTest.kt
package com.example.test

import com.example.api.FakeApiClient
import org.junit.Test

class ApiClientTest {

    @Test
    fun testFakeData() {
        val fakeApiClient = FakeApiClient()
        assert(fakeApiClient.getFakeData() == "Fake Data")
    }
}

Conclusion

The testFixtures feature in AGP 8.5.0 is a game-changer for modularized Android projects. It eliminates the friction of sharing test utilities, allowing you to write cleaner, more maintainable test setups. If you’re working on a modularized project and haven’t tried testFixtures yet, now is the perfect time to incorporate it into your testing strategy.

Understanding Referrals Using Google Play

| Comments

Tracking how users install your app can be tricky, especially when you want to measure the effectiveness of your campaigns or ads. That’s where the Google Play Install Referrer API comes in handy—it gives you reliable data about how users found your app.

If you’re developing an Android application, this guide will walk you through how to set up and use the InstallReferrerClient with Kotlin’s callbackFlow to make integration simpler and more maintainable.

What is the Install Referrer?

The install referrer contains information about the source of the app installation. For example, if a user clicks an ad campaign link that leads to your app’s Play Store page, the referrer might include details about the campaign source, medium, or other specifics.

Here’s how this data can help:

  • Attribution: Identify which campaigns drive installs.
  • Measure Success: Evaluate your marketing efforts’ impact.
  • Prevent Fraud: Verify referrer data to avoid fraudulent installs.

Setting Up and Using the InstallReferrerClient

Step 1: Add the Dependency

First, include the Install Referrer library in your build.gradle file:

1
implementation 'com.android.installreferrer:installreferrer:2.2'

Step 2: Use callbackFlow for our implementation

The InstallReferrerClient is callback-based, but Kotlin’s callbackFlow lets you handle this more elegantly. Here’s an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener
import com.android.installreferrer.api.ReferrerDetails
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow

fun fetchInstallReferrer(context: Context) = callbackFlow {
    val referrerClient = InstallReferrerClient.newBuilder(context).build()

    val listener = object : InstallReferrerStateListener {
        override fun onInstallReferrerSetupFinished(responseCode: Int) {
            when (responseCode) {
                InstallReferrerClient.InstallReferrerResponse.OK -> {
                    try {
                        val response: ReferrerDetails = referrerClient.installReferrer
                        trySend(Result.success(response))
                    } catch (e: Exception) {
                        trySend(Result.failure(e))
                    } finally {
                        referrerClient.endConnection()
                    }
                }
                InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED -> {
                    trySend(Result.failure(UnsupportedOperationException("Feature not supported")))
                }
                InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE -> {
                    trySend(Result.failure(IllegalStateException("Service unavailable")))
                }
            }
        }

        override fun onInstallReferrerServiceDisconnected() {
            // Handle service disconnection if needed
        }
    }

    referrerClient.startConnection(listener)

    awaitClose {
        referrerClient.endConnection()
    }
}

Step 3: Consuming the Flow

Collect the flow to retrieve and process the install referrer details:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fetchInstallReferrer(context).collect { result ->
        result.onSuccess { referrerDetails ->
            val referrerUrl = referrerDetails.installReferrer
            val clickTimestamp = referrerDetails.referrerClickTimestampSeconds
            val installTimestamp = referrerDetails.installBeginTimestampSeconds

            println("Referrer URL: $referrerUrl")
            println("Click Time: $clickTimestamp")
            println("Install Time: $installTimestamp")
        }

        result.onFailure { exception ->
            println("Error: ${exception.message}")
        }
    }

Wrapping Up

With this data, you’ll gain valuable insights into your app’s install sources, helping you make data-driven marketing decisions. Happy coding!

Accessing Test Resources Using Kotlin Multiplatform

| Comments

Kotlin Multiplatform (KMP) offers a powerful way to share code across platforms like Android, iOS, and the JVM. However, when it comes to testing, you might encounter a common challenge: how to handle test resources such as JSON files, configurations, or other data needed for tests. In this post, we’ll dive into strategies for accessing test resources in KMP projects.

Why Test Resources Matter

Test resources are essential for validating your code against real-world scenarios. For example, if you’re writing a library to parse JSON, you’d want to test it against diverse JSON samples representing different edge cases. While resource access is straightforward in single-platform projects, KMP’s multi-target nature requires some additional setup.

The Challenge in Kotlin Multiplatform

In KMP, test code resides in the commonTest source set, but resources aren’t directly bundled with it. Each platform manages file paths and resource access differently, so you need platform-specific setups for your resources and shared logic to load them efficiently.

Organizing Test Resources

Start by structuring your resources in a way that aligns with your project layout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
project-root/
  src/
    commonTest/
      kotlin/
        ...
    androidTest/
      resources/
        test-data.json
    jvmTest/
      resources/
        test-data.json
    iosTest/
      resources/
        test-data.json

This setup ensures that each target platform can access its respective resources while keeping them logically grouped.

Loading Resources by Platform

For Android, place your test resources in the androidTest/resources directory. Use the javaClass.classLoader to load them:

1
2
3
4
5
fun loadTestResource(resourceName: String): String {
    val inputStream = javaClass.classLoader?.getResourceAsStream(resourceName)
    return inputStream?.bufferedReader()?.use { it.readText() }
        ?: throw IllegalArgumentException("Resource not found: $resourceName")
}

For iOS, add your resources to the test target in Xcode. Use platform-specific code to load them:

1
2
3
4
5
6
7
8
import platform.Foundation.*

fun loadTestResource(resourceName: String): String {
    val bundle = NSBundle.bundleForClass(MyTestClass::class)
    val path = bundle.pathForResource(resourceName, ofType = null)
        ?: throw IllegalArgumentException("Resource not found: $resourceName")
    return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as String
}

Sharing the Resource Loader

To avoid duplication, define a common function in commonTest and use the expect/actual pattern for platform-specific implementations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// commonTest
expect fun loadTestResource(resourceName: String): String

// androidTest
actual fun loadTestResource(resourceName: String): String {
    val inputStream = javaClass.classLoader?.getResourceAsStream(resourceName)
    return inputStream?.bufferedReader()?.use { it.readText() }
        ?: throw IllegalArgumentException("Resource not found: $resourceName")
}

// iosTest
actual fun loadTestResource(resourceName: String): String {
    val bundle = NSBundle.bundleForClass(MyTestClass::class)
    val path = bundle.pathForResource(resourceName, ofType = null)
        ?: throw IllegalArgumentException("Resource not found: $resourceName")
    return NSString.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null) as String
}

Putting It All Together

With this setup, your commonTest code can seamlessly access resources across platforms. For example:

1
2
3
4
5
6
@Test
fun testJsonParsing() {
    val jsonData = loadTestResource("test-data.json")
    val parsedData = parseJson(jsonData)
    assertEquals(expectedData, parsedData)
}

Accessing test resources in Kotlin Multiplatform involves a bit of extra setup, but the payoff is a unified testing strategy across platforms. By leveraging the expect/actual pattern, you can create platform-specific resource loaders while keeping your test logic shared and consistent. Dive into KMP’s capabilities, and streamline your tests for every target platform!

Building Flipper Plugins for Fun and Profit

| Comments

A long, long time ago, I wrote a blog post about how I was using Flipper as one of my favorite development tools. Since then, Android Studio has come a long way adding tons of features like a new Logcat and Layout Inspector.

However, there are often times that you’ll need a tool more specific to your own workflow that Android Studio doesn’t provide, and that’s exactly where Flipper’s extensibility really shines. As an example, I’d like to go through building a custom plugin for Flipper, similar to one that I’ve used on my own projects, that demonstrates how easy it is to get started building these tools.

Improving App Debugging With Flipper

| Comments

Some time last year, Facebook released a new mobile debugging tool, named Flipper. It’s essentially the successor to the widely popular Stetho. Although after talking to many developers, it seems like this newer tool is relatively unknown.

Like Stetho, Flipper has many built-in features – including a layout inspector, a database inspector and a network inspector. Unlike Stetho though, Flipper has a very extensible API which allows for tons of customization. Over the next few articles, we’re going to take a look at Flipper and its plugins, the APIs it provides, and how we can leverage them to help us debug various parts of our app. This post will focus on getting set up with Flipper, as well as taking a look at two of its most useful default plugins.

Getting Started

Getting started with Flipper is really easy:

1
2
3
4
5
6
7
8
9
10
repositories {
  jcenter()
}

dependencies {
  debugImplementation 'com.facebook.flipper:flipper:0.33.1'
  debugImplementation 'com.facebook.soloader:soloader:0.8.2'

  releaseImplementation 'com.facebook.flipper:flipper-noop:0.33.1'
}
  • Initialize the Flipper client when your application starts:
1
2
3
4
5
6
7
8
9
10
11
12
class SampleApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        SoLoader.init(this, false)

        if (BuildConfig.DEBUG && FlipperUtils.shouldEnableFlipper(this)) {
            val client = AndroidFlipperClient.getInstance(this)
            client.addPlugin(InspectorFlipperPlugin(this, DescriptorMapping.withDefaults()))
            client.start()
        }
    }
}

And that’s it! Opening the desktop client should show you an overview of your app with the Inspector plugin configured.

Inspector Plugin

The Inspector Plugin is similar to the one found in Android Studio 4.0, but has a few neat features. I like it because it operates in real-time, and doesn’t require any attaching to process in Studio every time you want to inspect a layout.

Another thing you can do in the Layout Inspector that’s really cool is actually edit properties! Pretty mind blowing to make tweaks in the inspector and watch the views change in realtime. It’s really handy for experimenting with changing padding, and text colors. It doesn’t actually edit any of your xml files, but this allows you to iterate quickly to make sure everything looks right.

Let’s find a view we want to update (like our repository name):

We can click on the the color swatch to open a color picker:

And now when we look over at our device:

Neat!

Database Browser / Plugin

Something I’ve wanted for a long time was a way to view the contents of my database from Android Studio. Right now, if you want to visualize your data or try out some queries – the best solution is to pull the sqlite database file off your emulator/device and run sqlite locally. But with Flipper, there’s a better way!

All we need to do is configure the database plugin, and our tables should show up right away:

1
client.addPlugin(DatabasesFlipperPlugin(context))

Now we can easily inspect the contents of our tables, and even run queries on the live running application!

I’ve pushed a branch of the Github Browser Architecture Component sample with these changes to GitHub if you’d like to try it out. Next time we’ll take advantage of Flipper’s extensibility to create our own plugins to make debugging our app easier!

Dropping Columns Like It’s Hot

| Comments

Recently, I was doing some code cleanup and noticed that there were some data in the database that was no longer needed. I think most developers clean up their codebase of deprecated patterns and unused code, but I personally have not done a good job of ensuring that the same cleanup happens for unused columns in my databases.

Dropping tables that are no longer used is pretty easy (especially if you can just use something like Room’s Migrations) but when trying to remove unused columns, I ran into an unexpected problem. I thought to myself, it’s pretty easy to add or rename a column, why would dropping one be any harder? The existing database library I was using already had a convenient “drop column” method, so I simply called that and tried to run the migration. During the process, I ended up with a ForeignKeyConstraintException! I quickly scanned the schema to see what could have caused that, and didn’t see anything obvious. The table I was trying to modify didn’t have any foreign keys itself, and the column I was dropping was not a foreign key. Curious to understand what was happening, I started to dig into what this method call was doing.