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 TypeDescriptionTesting Approach
Core Logic (Java/Kotlin)Business logic written in Kotlin or Java, independent of Android frameworkLocal Unit Tests (run on JVM)
Android Framework CodeClasses 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"]
    end

Example

// ✗ 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 + b


Types of Android Tests

Test TypeDescriptionRequirements
Local Unit TestsTest business logic on JVM without Android dependenciesNo device/emulator needed
Instrumentation TestsTest Android-dependent code, requiring a real device or emulatorDevice or emulator required
UI TestsTest user interface interactions and behaviorsDevice or emulator required
Non-UI Android TestsTest Android components like Contacts, Asset Manager without UI interactionDevice 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:

  1. Local Unit Tests: Run on the developer's machine JVM.
  2. Instrumentation Tests: Run on an Android device or emulator.

Dependencies are declared differently for each test type:

Dependency TypeUsage
testImplementationFor JVM local unit tests (core logic)
androidTestImplementationFor 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

StepDescription
Write test casesProvide input and expected output for a method or class
Run local unit testsExecute business logic tests on JVM
Run instrumentation testsExecute Android-dependent tests on device/emulator
Analyze test resultsConfirm if output matches expected output, fixing bugs if tests fail


Important Concepts

ConceptExplanation
IsolationTests should depend only on given inputs, not on external state or randomness
Separation of ConcernsKeep business logic separate from Android framework code for easier testing
Dependency DeclarationUse testImplementation for JVM tests and androidTestImplementation for Android tests
InstrumentationTests 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 TypeEnvironmentRequires Device/EmulatorSpeedPurpose
Local Unit TestJVM (Developer PC)NoFastTest core business logic
Instrumentation TestAndroid Device/EmulatorYesSlowerTest 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.