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:
- Arrange
- Act
- 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.Mainin 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
runTestfor coroutine-based code. - Test
Flowemissions in sequence. - Use dependency injection to replace production dependencies.
- Keep UI tests focused on critical user journeys.
- Run tests automatically in CI.
Interview Questions
- Why is automated testing important?
- Explain the Testing Pyramid.
- What’s the difference between unit, integration, instrumentation, and UI tests?
- What is Arrange–Act–Assert?
- Compare mocks, stubs, fakes, dummies, and spies.
- Why are fakes often preferred over mocks?
- How do you test coroutine-based code?
- How would you test a
Flow? - How does Hilt improve testability?
- 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.