On this page
Many Android developers skip tests because UI tests felt slow and flaky. Unit tests are different: they test plain Kotlin logic (ViewModels, mappers, validators, reducers) on the JVM, with no device needed. A few hundred of them run in seconds.
Where unit tests fit
What to unit test
- ViewModels: state changes in response to events and data.
- Business logic: validators, calculators, use cases, reducers.
- Mappers: DTO to domain to UI conversions, especially edge cases.
- Repositories: caching and merging logic, with fake data sources.
Don't unit test framework code (Room's SQL is better tested with an in-memory DB in instrumented tests) or trivial getters.
Setup
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>")
testImplementation("com.google.truth:truth:<version>") // optional: readable assertions
}A well-structured test
object PasswordValidator {
sealed interface Result {
data object Valid : Result
data class Invalid(val reasons: List<String>) : Result
}
fun validate(password: String): Result {
val reasons = buildList {
if (password.length < 8) add("At least 8 characters")
if (password.none(Char::isDigit)) add("At least one number")
if (password.none(Char::isUpperCase)) add("At least one uppercase letter")
}
return if (reasons.isEmpty()) Result.Valid else Result.Invalid(reasons)
}
}class PasswordValidatorTest {
@Test
fun `valid password passes`() {
assertEquals(PasswordValidator.Result.Valid, PasswordValidator.validate("Secure123"))
}
@Test
fun `short password without digits lists both reasons`() {
// Given a short, letters-only password
val password = "Abc"
// When validated
val result = PasswordValidator.validate(password)
// Then both problems are reported
assertEquals(
PasswordValidator.Result.Invalid(listOf("At least 8 characters", "At least one number")),
result,
)
}
}Backtick names describe behaviour in plain language, and the given/when/then structure keeps tests readable.
Fakes, stubs and mocks
| Double | What it is | When |
|---|---|---|
| Fake | A working lightweight implementation (in-memory repository) | Default choice for repositories and data sources |
| Stub | Returns canned values | Simple dependencies |
| Mock | Records and verifies calls (MockK, Mockito) | Verifying interactions such as "analytics logged once" |
Google's testing guidance favours fakes where practical. They make tests describe behaviour rather than implementation details. See MockK vs Mockito.
Testing many cases
@Test
fun `formats durations`() {
val cases = mapOf(0L to "0:00", 59L to "0:59", 61L to "1:01", 3_600L to "60:00")
cases.forEach { (seconds, expected) ->
assertEquals("for $seconds s", expected, formatDuration(seconds))
}
}When code touches Android classes
Local tests run on the JVM with a stub android.jar, so Android framework calls throw by default. Options:
- Design it away: keep logic in classes without Android dependencies (best).
- Inject abstractions: for example a
StringProviderorClockinstead ofContextorSystem.currentTimeMillis(). - Robolectric: runs Android framework code on the JVM when you really need it.
Running tests
./gradlew testDebugUnitTest # all local unit tests for the debug variant
./gradlew :feature:cart:testDebugUnitTest # one module
# HTML report: <module>/build/reports/tests/testDebugUnitTest/index.htmlHigh-value tests
Money and dates
Rounding, currency formatting, time zones. Bugs here are costly and easy to test.
State transitions
Reducers and ViewModels: loading to success, error to retry.
Bug regressions
Every fixed bug gets a test that would have caught it.
Common mistakes
- Testing implementation details (private methods, call order) so every refactor breaks tests.
- Using real time, randomness or the network in unit tests.
- Huge setup shared by unrelated tests. Prefer small builders and fakes.
- No assertions, or assertions that can't fail.
Frequently asked questions
JUnit 4 or JUnit 5?
Android's tooling and many AndroidX test libraries are built around JUnit 4, which is the simplest default. JUnit 5 can be used for local tests with additional setup.
How much coverage should I aim for?
Coverage is a weak target. Aim to cover business logic and state transitions thoroughly, and use coverage reports to find untested risky code.
Can AI write my tests?
AI tools can draft tests, but you must check that they assert meaningful behaviour. See AI-Assisted Unit Test Generation.
Conclusion
Unit tests are fast, cheap and high-value when they target logic: ViewModels, validators, mappers and use cases. Keep logic free of Android classes, prefer fakes, name tests by behaviour, and run them on every commit.
Next step: test asynchronous code properly in Testing ViewModels with Coroutines and Flow.
Official references
๐ฑ Apps built by Yunok Tech
Real Android apps on Google Play, free to download.
Walzi4K wallpapers for lock and home screenGet it โธ
NotiVaultNotification history: recover dismissed notificationsGet it โธ
Ready to build real Android applications?
Performance work starts with understanding how Android apps are built and how they behave at runtime. Strengthen those fundamentals and the profiling tools start to make sense.
Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.
Explore the Android course and take your next step as an Android developer.