Module 16 of 20

Testing (Unit Testing, Instrumentation, UI Testing & Test Architecture)

Write clean, testable code and implement unit tests, instrumentation tests, UI tests, and test-driven architecture.

Module 16: Testing (Unit Testing, Instrumentation, UI Testing & Test Architecture)

Learning Objectives

By the end of this module, you’ll understand:

  • Why testing exists
  • Types of testing
  • The Testing Pyramid
  • Testability
  • Unit testing
  • Integration testing
  • Instrumentation testing
  • UI testing
  • JUnit
  • Assertions
  • Test lifecycle
  • Test doubles
  • Mocks, Fakes, Stubs, Spies
  • Mockito vs MockK
  • Coroutine testing
  • Flow testing
  • Repository testing
  • ViewModel testing
  • Hilt testing
  • Compose UI testing
  • Best testing practices

Part 1 — Why Do We Test?

Imagine this ViewModel:

class LoginViewModel(
    private val repository: LoginRepository
)

Today it works.

Tomorrow someone changes:

if(password.length > 6)

to

if(password.length > 10)

Suddenly thousands of users cannot log in.

Nobody noticed.

Why?

No tests.


Testing exists to answer one question:

Can we automatically verify that our software still behaves correctly?


Part 2 — Manual Testing vs Automated Testing

Manual:

Open app



Click Login



Observe Result

Problems:

  • Slow
  • Repetitive
  • Human mistakes
  • Doesn’t scale

Automated:

Run Tests



Thousands Execute



Pass / Fail

In minutes.


Part 3 — Testing Pyramid

One of the most important concepts.

                 UI Tests
             (Few, Expensive)
            ------------------
           Integration Tests
        (Some, Medium Cost)
      -------------------------
          Unit Tests
   (Many, Fast, Cheap)

Professional projects usually have:

  • Many Unit Tests
  • Some Integration Tests
  • Few UI Tests

Why?

Unit tests are:

  • Fast
  • Reliable
  • Cheap

UI tests are:

  • Slow
  • Brittle
  • Expensive

Part 4 — Types of Tests

Unit Test

Tests one class.

Example:

Calculator



add(2,3)



5

No database.

No network.

No Android framework.


Integration Test

Tests multiple components together.

Example:

Repository



Room



SQLite

Verifies collaboration.


Instrumentation Test

Runs on a real device or emulator.

Can use:

  • Context
  • Activities
  • Views
  • Compose
  • Resources

Slower than unit tests.


UI Test

Simulates user actions.

Example:

Click Login



Enter Email



Verify Home Screen

Tests the whole user journey.


Part 5 — Good Test Characteristics

A good test is:

  • Fast
  • Independent
  • Repeatable
  • Deterministic
  • Readable

Bad test:

Sometimes passes

Sometimes fails

This is called a flaky test.

Flaky tests reduce trust in the test suite.


Part 6 — JUnit

JUnit is the standard testing framework for JVM-based projects.

Typical lifecycle:

Run Test



Setup



Execute



Assert



Cleanup

Basic structure:

@Test
fun addition_isCorrect() {
    val result = 2 + 2
    assertEquals(4, result)
}

Three phases:

  1. Arrange
  2. Act
  3. Assert

Arrange–Act–Assert (AAA)

A pattern used in almost every unit test.

Arrange


Create Objects



Act



Call Function



Assert



Verify Result

Example:

// Arrange
val calculator = Calculator()

// Act
val result = calculator.add(2, 3)

// Assert
assertEquals(5, result)

Part 7 — Assertions

Assertions verify expectations.

Examples:

assertEquals(...)
assertTrue(...)
assertFalse(...)
assertNull(...)
assertNotNull(...)
assertThrows(...)

Without assertions:

A test isn’t actually checking anything.


Part 8 — Test Lifecycle

JUnit provides setup and teardown hooks.

BeforeEach



Test



AfterEach

Useful for:

  • Creating objects
  • Resetting state
  • Cleaning temporary files

Part 9 — Test Doubles

One of the most important testing concepts.

Imagine:

ViewModel



Repository



Server

You don’t want real network calls during a unit test.

Replace the dependency.

This replacement is called a Test Double.


Dummy

Never used.

Only fills a parameter.

Example:

User(id = 0)

Passed because the method requires it.


Stub

Returns predefined values.

Example:

Repository



Always Returns User

Useful when you only care about inputs and outputs.


Fake

Has a working implementation.

Example:

Fake Database



Stores Data In Memory

Unlike a stub, it behaves realistically but avoids external systems.

Fakes are often preferred over mocks for business logic tests.


Mock

Verifies interactions.

Example:

Did repository.save() get called?

A mock checks behavior, not just results.


Spy

Wraps a real object while allowing verification.

Useful when you want the real implementation but still observe interactions.


Mockito vs MockK

Mockito:

  • Mature
  • Java-first
  • Works well in Kotlin

MockK:

  • Designed specifically for Kotlin
  • Better support for:
    • Coroutines
    • Objects
    • Top-level functions
    • Extension functions

Modern Kotlin projects often prefer MockK.


Part 10 — Testing ViewModels

Suppose:

LoginViewModel



Repository

Don’t use the real repository.

Instead:

LoginViewModel



Fake Repository

Now the ViewModel can be tested in isolation.

Typical things to verify:

  • Loading state
  • Success state
  • Error state
  • Validation logic

Example scenario:

Fake Repository



Returns Success



ViewModel



UI State = Success

Then another test:

Fake Repository



Throws Exception



ViewModel



UI State = Error

Part 11 — Testing Repositories

Repositories usually coordinate:

Room

+

Retrofit

Tests should verify:

  • Reads from cache when appropriate
  • Refreshes from network
  • Saves remote data locally
  • Handles failures correctly

Many repository tests use fake DAOs and fake APIs.


Part 12 — Coroutine Testing

Coroutines introduce concurrency.

Real delays make tests slow.

Example:

delay(5000)

Waiting five seconds in every test is unacceptable.

The coroutine test library provides:

runTest { ... }

It uses a virtual clock.

Meaning:

Delay 5 Seconds



Test Finishes Almost Instantly

Important utilities include:

  • runTest
  • Test dispatchers
  • Advancing virtual time
  • Replacing Dispatchers.Main in tests

Part 13 — Testing Flow

Suppose:

Repository



Flow<User>

The test verifies emissions.

Example sequence:

Loading



Success

or

Loading



Error

A common library is Turbine, which makes Flow assertions straightforward.

Conceptually:

Collect Flow



Observe Emissions



Verify Order

Part 14 — Compose UI Testing

Compose provides a dedicated testing API.

Instead of finding views by IDs, tests interact with the semantics tree.

Conceptually:

Find Button



Perform Click



Verify Text Exists

Tests focus on user-visible behavior rather than implementation details.


Typical interactions:

  • Click buttons
  • Enter text
  • Scroll lists
  • Assert displayed content

Part 15 — Espresso

For View-based UIs:

Find View



Click



Assert

Espresso synchronizes with the UI thread, reducing timing issues.

Compose has its own testing framework, so new Compose-only apps often don’t use Espresso extensively.


Part 16 — Hilt Testing

Production:

Repository



Real API

Testing:

Repository



Fake API

Hilt allows replacing production dependencies with test implementations.

This is one of DI’s biggest advantages.

Without DI:

Testing becomes difficult.

With DI:

Swap implementations easily.


Part 17 — Testability

Good architecture naturally improves testing.

Bad:

ViewModel



Creates Repository



Creates Retrofit



Creates Database

Hard to test.

Good:

ViewModel



Repository Interface

Easy to replace with a fake.

Notice how Clean Architecture and DI directly support testing.


Part 18 — Code Coverage

Coverage answers:

How much code was executed during tests?

Example:

80%

Important:

High coverage does not guarantee good tests.

Example:

100% Coverage



Assertions Missing



Worthless

Quality matters more than the percentage.


Part 19 — Common Testing Strategy

A professional Android app often looks like:

UI


Few UI Tests


ViewModels


Many Unit Tests


Repositories


Many Unit Tests


Room/Retrofit


Some Integration Tests

Most logic lives in ViewModels and domain layers because they’re easy to test.


Part 20 — Continuous Integration (CI)

Imagine a team of 50 developers.

Every commit runs:

Compile



Unit Tests



Integration Tests



UI Tests



Build APK

If any test fails:

The change is rejected until fixed.

Testing is most valuable when automated in CI pipelines.


Common Mistakes

❌ Testing private methods

Test observable behavior through public APIs.

Private methods are implementation details.


❌ Mocking everything

Over-mocking creates fragile tests.

Prefer fakes when practical.


❌ Using real network calls in unit tests

Unit tests should be isolated and deterministic.


❌ Using Thread.sleep()

This slows tests and causes flakiness.

Use coroutine test utilities or synchronization mechanisms.


❌ Verifying implementation instead of behavior

Bad:

Did function A call function B?

Better:

Did the user receive the expected result?

❌ Writing huge tests

Each test should verify one behavior.

Small, focused tests are easier to understand and maintain.


Mental Model

Imagine a car factory.

Engine Test


Brake Test


Door Test


Electronics Test


Road Test

You don’t wait until the whole car is assembled to discover the engine doesn’t start.

Software testing follows the same principle.

Small components are verified first, then larger integrations.


Best Practices

  • Write many unit tests for business logic.
  • Keep tests deterministic and independent.
  • Prefer fakes over excessive mocking.
  • Test behavior, not implementation details.
  • Use runTest for coroutine-based code.
  • Test Flow emissions in sequence.
  • Use dependency injection to replace production dependencies.
  • Keep UI tests focused on critical user journeys.
  • Run tests automatically in CI.

Interview Questions

  1. Why is automated testing important?
  2. Explain the Testing Pyramid.
  3. What’s the difference between unit, integration, instrumentation, and UI tests?
  4. What is Arrange–Act–Assert?
  5. Compare mocks, stubs, fakes, dummies, and spies.
  6. Why are fakes often preferred over mocks?
  7. How do you test coroutine-based code?
  8. How would you test a Flow?
  9. How does Hilt improve testability?
  10. What makes a test flaky, and how do you avoid it?

Module 16 Summary

You now understand how professional Android teams verify software quality:

  • Unit tests validate individual classes quickly and reliably.
  • Integration tests verify collaboration between components.
  • Instrumentation and UI tests validate behavior on Android devices.
  • JUnit provides the foundation for test execution and assertions.
  • Test doubles isolate dependencies and make tests deterministic.
  • Coroutines and Flow require dedicated testing tools such as runTest.
  • Dependency Injection enables swapping production implementations for test doubles.
  • Compose and Espresso provide UI testing capabilities.
  • Continuous Integration ensures tests run automatically for every change.

Most importantly, you’ve seen how the architecture from previous modules—MVVM, Clean Architecture, Hilt, Repositories, Coroutines, Flow, Retrofit, and Room—was designed not only for maintainability but also for testability.


Next Module: Performance, Memory & Optimization

Module 17 is where you’ll learn how to make apps fast, efficient, and scalable.

We’ll cover:

  • Android memory management
  • The Java/Kotlin heap and Garbage Collection
  • Memory leaks and how to detect them
  • Context leaks and lifecycle pitfalls
  • LeakCanary
  • ANRs (Application Not Responding)
  • StrictMode
  • Startup optimization
  • Rendering pipeline (Choreographer, vsync, 16 ms frame budget)
  • Jank analysis
  • Baseline Profiles
  • R8 and ProGuard
  • APK/App Bundle optimization
  • Battery optimization
  • Profiling with Android Studio

This module is where you’ll begin thinking like a senior engineer who not only writes correct code but also writes efficient code.