Skip to content
All posts
July 2, 20263 min read

Android Testing Strategy for Solo Developers

Full test coverage is a fantasy for solo developers. A targeted strategy that covers what matters — ViewModel logic, repository contracts, and critical UI flows — without drowning in test maintenance.

AndroidTestingQASolo Dev
Share:

Testing is one area where solo developer advice diverges sharply from team advice. "Aim for 80% coverage" is correct for a team with dedicated QA engineers. For a solo developer shipping across 22+ apps, it's not a realistic target and it's not the right goal.

Here's a testing strategy that's actually sustainable.

What to test and why

Not everything deserves a test. The goal is to catch the bugs that would cost you the most time or embarrass you the most publicly.

Always test:

  • ViewModel logic — state transitions, error handling, edge cases
  • Repository layer — especially anything with caching logic or data transformation
  • Use cases with business rules — anything where "wrong" has a clear definition
  • Utility functions — pure functions are trivial to test and break in non-obvious ways

Test selectively:

  • Composables — only for components with significant logic or reuse
  • Navigation — only if your nav graph is complex
  • Data sources — mock at the Repository boundary, don't test Room internals

Don't bother:

  • Screens that are purely display with no logic
  • Trivial data class mappings
  • Anything that only tests the framework rather than your code

ViewModel tests

This is the highest-value test category. Your ViewModel contains your app's decision-making logic.

kotlin
@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val fakeRepository = FakeAuthRepository()
    private lateinit var viewModel: LoginViewModel

    @Before
    fun setup() {
        viewModel = LoginViewModel(fakeRepository)
    }

    @Test
    fun `valid credentials transition to Success state`() = runTest {
        fakeRepository.setLoginResult(Result.success(fakeUser))

        viewModel.onLoginClicked("user@test.com", "password")
        advanceUntilIdle()

        assertEquals(LoginUiState.Success(fakeUser), viewModel.uiState.value)
    }

    @Test
    fun `network error shows error state`() = runTest {
        fakeRepository.setLoginResult(Result.failure(IOException("no connection")))

        viewModel.onLoginClicked("user@test.com", "password")
        advanceUntilIdle()

        assertTrue(viewModel.uiState.value is LoginUiState.Error)
    }
}

Use fake implementations, not mocks. A

code
FakeAuthRepository
that implements the same interface as the real one is more readable, easier to configure, and less brittle than a mock with
code
verify()
calls.

MainDispatcherRule

You need this for every ViewModel test:

kotlin
class MainDispatcherRule(
    private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

Without it,

code
viewModelScope
will crash in tests because there's no main thread.

Repository tests

Test the Repository's contract — not the implementation details:

kotlin
class UserRepositoryTest {

    private val fakeApi = FakeUserApi()
    private val fakeDao = FakeUserDao()
    private val repository = UserRepository(fakeApi, fakeDao)

    @Test
    fun `getUser returns cached value when available`() = runTest {
        fakeDao.insert(cachedUser)

        val result = repository.getUser("user_id")

        assertEquals(cachedUser, result.getOrNull())
        assertEquals(0, fakeApi.callCount) // API not called
    }

    @Test
    fun `getUser fetches from API when cache is empty`() = runTest {
        fakeApi.setResponse("user_id", remoteUser)

        val result = repository.getUser("user_id")

        assertEquals(remoteUser, result.getOrNull())
        assertEquals(1, fakeApi.callCount)
    }
}

Compose UI tests — be selective

Compose UI tests are slow and brittle. Write them for:

  • Components used across many screens
  • Critical user flows (onboarding, checkout, login)
  • Anything that's hard to test in a ViewModel
kotlin
@Test
fun loginScreen_displaysErrorOnInvalidCredentials() {
    composeTestRule.setContent {
        LoginScreen(
            uiState = LoginUiState.Error("Invalid credentials"),
            onLoginClicked = {}
        )
    }

    composeTestRule
        .onNodeWithText("Invalid credentials")
        .assertIsDisplayed()
}

Pass state directly into Composables rather than testing through a ViewModel — it's faster and more reliable.

The sustainable approach

  1. Write ViewModel tests for every ViewModel — it's fast, it catches real bugs
  2. Write Repository tests when caching or transformation logic exists
  3. Write one or two UI tests for critical paths (login, main user flow)
  4. Skip the rest unless a specific bug warrants a regression test

This gives you meaningful coverage without a test suite that takes 20 minutes to run and breaks every time you rename a variable.

The goal isn't a percentage. It's catching the bugs that matter before your users do.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus