Android Unit Testing — Fundamentals
Android Unit Testing — Fundamentals
What is Unit Testing?
Unit Testing involves isolating a specific piece of code (a unit, such as a method or class), providing different inputs, executing its logic, and verifying whether the output matches the expected result.
- Purpose: To check if a unit of code behaves correctly for given inputs.
- Manual testing of features is time-consuming and error-prone, especially as software evolves.
- Automating unit tests helps ensure new code does not break existing functionality without repeated manual checks.
- A unit can be a method or a class.
- The unit test passes if the output equals the expected output for the given input.
Diagram — The Unit Test Flow
flowchart LR
A["Input\n(e.g. 2, 3)"] --> B["Unit\n(add() method)"]
B --> C["Actual Output\n(5)"]
C --> D{"Compare with\nExpected (5)"}
D -->|Match| E["✅ PASS"]
D -->|Mismatch| F["❌ FAIL"]Example
class Calculator {
fun add(a: Int, b: Int): Int = a + b
}
@Test
fun `add returns correct sum`() {
val calculator = Calculator()
val result = calculator.add(2, 3)
assertEquals(5, result) // PASS if result == 5
}Why Automate Unit Testing?
- Software continuously evolves with new features.
- Changes can break old features; hence, old features must be retested.
- Manual retesting is inefficient and slow.
- Automated tests enable automatic, extensible, and repeatable testing.
- Automation ensures quick feedback on code correctness.
Unit Testing in Android
Two Types of Code in Android Projects
| Code Type | Description | Testing Approach |
|---|---|---|
| Core Logic (Java/Kotlin) | Business logic written in Kotlin or Java, independent of Android framework | Local Unit Tests (run on JVM) |
| Android Framework Code | Classes interacting with Android runtime (Activities, Fragments, etc.) | Instrumentation Tests (run on device/emulator) |
Diagram — Code Separation & Test Mapping
flowchart TD
A[Android Project Code] --> B["Core Logic (Kotlin/Java)\nBusiness logic, independent\nof Android framework"]
A --> C["Android Framework Code\nActivities, Fragments,\nAndroid runtime classes"]
B --> D["Local Unit Tests\ntestImplementation\nRuns on JVM · Fast · No device"]
C --> E["Instrumentation Tests\nandroidTestImplementation\nRuns on device/emulator · Slower"]Challenges in Android Unit Testing
- Android classes are closely tied to the runtime environment, making isolated testing difficult.
- Good architecture helps separate business logic from Android framework code.
- This separation allows testing core logic locally and Android-dependent code with instrumentation tests.
Example
A Presenter or ViewModel holding only Kotlin logic can be unit tested directly, but an Activity that reads Intent extras or inflates a layout needs the Android runtime — so it moves to instrumentation tests:
// Core logic — testable with a Local Unit Test (JVM only)
class PriceCalculator {
fun applyDiscount(price: Double, percent: Double): Double =
price - (price * percent / 100)
}
// Framework-dependent — needs Instrumentation Test (device/emulator)
class CheckoutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_checkout) // requires Android runtime
}
}Isolation in Unit Testing
- Tests should be isolated: output depends only on input provided, not on other components.
- Avoid randomness or external dependencies (like random number generators) that cause variable outputs.
- Isolated tests provide reliable and consistent results.
- Isolation ensures tests validate the unit's correctness without interference.
Diagram — Isolated vs. Non-Isolated Test
flowchart LR
subgraph Good["✓ Isolated Test"]
direction TB
G1["Input(2,3) → add() → 5"]
G2["Same input always gives\nthe same output"]
end
subgraph Bad["✗ Non-Isolated Test"]
direction TB
B1["Input(2,3) → add(Random())"]
B2["Output varies each run —\nunreliable, flaky test"]
endExample
// ✗ NOT isolated — depends on external/random state
fun rollAndAdd(a: Int): Int {
val bonus = Random.nextInt(1, 6) // changes every run
return a + bonus
}
// ✓ Isolated — output depends only on declared input
fun add(a: Int, b: Int): Int = a + bTypes of Android Tests
| Test Type | Description | Requirements |
|---|---|---|
| Local Unit Tests | Test business logic on JVM without Android dependencies | No device/emulator needed |
| Instrumentation Tests | Test Android-dependent code, requiring a real device or emulator | Device or emulator required |
| UI Tests | Test user interface interactions and behaviors | Device or emulator required |
| Non-UI Android Tests | Test Android components like Contacts, Asset Manager without UI interaction | Device or emulator required |
Diagram — Test Type Breakdown
flowchart TD
A[Types of Android Tests] --> B["Local Unit Tests\nNo device/emulator needed"]
A --> C["Instrumentation Tests\nDevice/emulator required"]
C --> D["UI Tests\nuser interface interactions"]
C --> E["Non-UI Android Tests\nContacts, Asset Manager, etc."]Android Test Setup in Android Studio
Android Studio project includes separate folders for:
- Local Unit Tests: Run on the developer's machine JVM.
- Instrumentation Tests: Run on an Android device or emulator.
Dependencies are declared differently for each test type:
| Dependency Type | Usage |
|---|---|
testImplementation | For JVM local unit tests (core logic) |
androidTestImplementation | For Android instrumentation tests (framework-dependent tests) |
Example — build.gradle.kts
dependencies {
// Local unit tests -> src/test -> runs on JVM
testImplementation("junit:junit:4.13.2")
// Instrumentation tests -> src/androidTest -> runs on device/emulator
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}Running Tests in Android Studio
- Local Unit Tests run instantly on the developer's machine.
- Instrumentation Tests run on emulator or real device, so they take longer.
- Running tests shows clear PASS or FAIL results based on expected vs actual output.
- Example: A test expecting 5 but getting 4 will fail, indicating a bug in functionality.
Summary of Unit Testing Process in Android
| Step | Description |
|---|---|
| Write test cases | Provide input and expected output for a method or class |
| Run local unit tests | Execute business logic tests on JVM |
| Run instrumentation tests | Execute Android-dependent tests on device/emulator |
| Analyze test results | Confirm if output matches expected output, fixing bugs if tests fail |
Important Concepts
| Concept | Explanation |
|---|---|
| Isolation | Tests should depend only on given inputs, not on external state or randomness |
| Separation of Concerns | Keep business logic separate from Android framework code for easier testing |
| Dependency Declaration | Use testImplementation for JVM tests and androidTestImplementation for Android tests |
| Instrumentation | Tests that run on Android devices or emulators requiring Android runtime environment |
Example Metaphor for Understanding Isolation
Testing a unit of code is like testing a machine with controlled inputs to check if it produces the correct outputs without interference from other machines or random factors.
If the machine has a random part that changes output every time, reliable testing becomes impossible. Similarly, unit tests avoid such randomness to confirm correctness reliably.
Test Execution Flow Summarized
| Test Type | Environment | Requires Device/Emulator | Speed | Purpose |
|---|---|---|---|---|
| Local Unit Test | JVM (Developer PC) | No | Fast | Test core business logic |
| Instrumentation Test | Android Device/Emulator | Yes | Slower | Test Android framework integration |
Unit testing in Android requires understanding the distinction between core logic and Android framework code, isolating tests to ensure reliability, and using appropriate tools and environments for execution. This structure enables developers to maintain high-quality code that can evolve without breaking existing functionality.