Integrating Unit Tests into Android Projects in 2024
What is a unit test?
In the context of Android development, a unit test targets the smallest testable parts of an application, such as individual methods within a class. The testing pyramid places unit tests at the fonudation because they are fast to run and provide immediate feedback on logic correctness. Any class in your project, from TextFormatter.kt to UserRepository.kt, and even presentation layer components like LoginViewModel.kt, can be considered a unit under test.
Why are they necessary?
Consider a scenario where a utility is required to extract the numeric version from a system string like "Android OS 14". The initial implementation might look like this:
object SystemVersionParser {
fun extractVersionCode(rawName: String): Int {
val pattern = Regex("\\d+")
val match = pattern.find(rawName)
return match?.value?.toIntOrNull() ?: -1
}
}
A new team member might refactor this, assuming the prefix never changes:
object SystemVersionParser {
fun extractVersionCode(rawName: String): Int {
return try {
rawName.removePrefix("Android OS ").toInt()
} catch (e: Exception) {
-1
}
}
}
This refactoring appears cleaner but fails when the string changes to "Android OS 14 (Special Edition)". Without tests, this regression might only be caught after deployment, leading to data parsing errors.
How unit tests prevent regressions
If the original developer had provided a test class, the issue would have been caught immediately during the build process.
import org.junit.Assert.assertEquals
import org.junit.Test
class SystemVersionParserTest {
@Test
fun `test standard version extraction`() {
val result = SystemVersionParser.extractVersionCode("Android OS 14")
assertEquals(14, result)
}
@Test
fun `test version extraction with suffix`() {
val result = SystemVersionParser.extractVersionCode("Android OS 14 (Special Edition)")
assertEquals(14, result)
}
}
When the new developer runs these tests after their refactor, the test version extraction with suffix case fails. This acts as a safety net, ensuring that changes do not break existing logic. In a professional CI/CD environment, these tests run automatically, preventing merges that cause regressions.
Case study: Testing in AOSP
The Android Open Source Project (AOSP) relies heavily on continuous integration. Contributors submit patches to the main branch, but these are only merged after passing rigorous checks. A key component of this process is atest, a test harness that runs unit tests defined in TEST_MAPPING files.
For example, the Settings application module defines its test suites to run before submission (presubmit):
{
"presubmit": [
{
"name": "SettingsUnitTests",
"options": [
{ "include-filter": "com.android.settings.display" },
{ "include-filter": "com.android.settings.network" }
]
}
]
}
This configuration ensures that any modification to the display or network settings logic triggers the associated unit tests. If a test fails, the submission is blocked, maintaining the stability of the platform despite having thousands of contributors.
Do unit tests replace manual QA?
Unit tests cannot replace manual testing. While unit tests verify that a specific function calculates 1 + 1 = 2 under various conditions, they do not assess the user experience. Manual testing is required to evaluate UI fluidity, accessibility, and complex user flows that are difficult to simulate in code. However, by offloading logic verification to automated tests, QA teams can focus more on exploratory testing and usability.