Lesson 38 of 83 intermediate

Testing Strategy: Unit vs Instrumentation vs UI Tests

Know what to test, at which level, and why — before writing a single line of test code

Open interactive version (quiz + challenge)

Real-world analogy

The testing pyramid is like quality control in a car factory. Unit tests are like checking each bolt individually at the workbench — fast, cheap, done thousands of times a day. Instrumentation tests are like testing the assembled engine on a test bench — slower, more realistic. UI tests are like a full test drive on a track — the most realistic but you can only do a few before the day ends.

What is it?

Testing strategy defines which types of tests you write, how many of each, and in what order — before writing any test code. The Android testing pyramid guides you to invest heavily in fast, cheap unit tests, moderately in integration tests, and minimally in slow UI tests. Understanding the cost-benefit of each level and the difference between test doubles (mock, fake, stub) lets you build a test suite that actually catches bugs without becoming a maintenance burden.

Real-world relevance

When building a school management app from scratch, the team had zero tests. Rather than starting with UI tests (the most obvious choice for 'does the app look right'), the lead engineer prioritized unit tests for the grade calculation engine, attendance rule logic, and report card generation — the core business rules. These caught 12 bugs in the first week without running the app once. UI tests were added only for the parent-facing grade report flow and the admin login — the two journeys where bugs would cause the most damage.

Key points

Code example

// Project structure — where tests live
//
// src/
//   main/java/com/schoolapp/       ← Production code
//   test/java/com/schoolapp/       ← Unit tests (JVM only, fast)
//   androidTest/java/com/schoolapp/ ← Instrumentation tests (device required)

// ---- UNIT TEST example (src/test/) ----
// Tests pure Kotlin business logic — no Android dependencies
class GradeCalculatorTest {

    private val calculator = GradeCalculator()  // Real instance, no mocks needed

    @Test
    fun `passing score returns PASS grade`() {
        val result = calculator.evaluate(score = 75, maxScore = 100, passMark = 60)
        assertThat(result.grade).isEqualTo(Grade.PASS)
        assertThat(result.percentage).isEqualTo(75.0)
    }

    @Test
    fun `score below pass mark returns FAIL grade`() {
        val result = calculator.evaluate(score = 45, maxScore = 100, passMark = 60)
        assertThat(result.grade).isEqualTo(Grade.FAIL)
    }

    @Test
    fun `zero max score throws IllegalArgumentException`() {
        assertThrows<IllegalArgumentException> {
            calculator.evaluate(score = 0, maxScore = 0, passMark = 60)
        }
    }
}

// ---- Test doubles: Stub, Mock, Fake ----

// STUB — returns hardcoded value, no verification
class StubStudentRepository : StudentRepository {
    override suspend fun getStudent(id: String) = Student("1", "Alice", grade = 10)
    override suspend fun saveStudent(student: Student) { /* no-op */ }
}

// FAKE — real working implementation backed by in-memory storage
class FakeStudentRepository : StudentRepository {
    private val storage = mutableMapOf<String, Student>()

    override suspend fun getStudent(id: String) = storage[id]
        ?: throw NotFoundException("Student $id not found")

    override suspend fun saveStudent(student: Student) {
        storage[student.id] = student
    }
}

// MOCK — created by MockK, allows call verification
// val mockRepo = mockk<StudentRepository>()
// every { mockRepo.getStudent("1") } returns Student("1", "Alice", 10)
// coVerify { mockRepo.getStudent("1") }   ← verifies the call happened

Line-by-line walkthrough

  1. 1. src/test/java is for unit tests — Gradle runs these on the JVM with no Android device, so they run in milliseconds.
  2. 2. src/androidTest/java is for instrumentation tests — these require a connected device or emulator and take much longer to run.
  3. 3. GradeCalculatorTest instantiates the real GradeCalculator — no mocks needed for pure logic classes with no external dependencies.
  4. 4. Each @Test method tests exactly one behavior with a descriptive backtick string name — 'passing score returns PASS grade' reads like a specification.
  5. 5. assertThrows verifies that the class correctly validates its inputs and fails fast with a meaningful error.
  6. 6. StubStudentRepository always returns the same hardcoded Student — useful when you only care about the system under test and not the repository behavior.
  7. 7. FakeStudentRepository uses a mutableMapOf as in-memory storage — it behaves like a real repository (throws on missing keys) without needing Room or SQLite.
  8. 8. The MOCK approach uses MockK's every { } to define return values and coVerify { } to assert that specific calls happened — useful when testing that side effects occur.

Spot the bug

// A developer wrote these tests for a payment processing use case.
// Identify what is wrong with the testing approach.

class ProcessPaymentUseCaseTest {

    @Test
    fun testProcessPayment() {
        // Bug 1 — test setup
        val mockRepo = mockk<PaymentRepository>()
        val mockNotifier = mockk<NotificationService>()
        val mockLogger = mockk<AuditLogger>()
        val mockValidator = mockk<PaymentValidator>()
        val mockFormatter = mockk<CurrencyFormatter>()
        val useCase = ProcessPaymentUseCase(
            mockRepo, mockNotifier, mockLogger, mockValidator, mockFormatter
        )

        every { mockValidator.validate(any()) } returns true
        every { mockFormatter.format(any()) } returns "$100.00"
        every { mockRepo.save(any()) } returns Unit
        every { mockNotifier.send(any()) } returns Unit
        every { mockLogger.log(any()) } returns Unit

        // Bug 2 — test body
        useCase.process(Payment(amount = 100.0, currency = "USD"))

        // Bug 3 — assertions
        coVerify { mockValidator.validate(any()) }
        coVerify { mockFormatter.format(100.0) }
        coVerify { mockRepo.save(any()) }
        coVerify { mockNotifier.send(any()) }
        coVerify { mockLogger.log(any()) }
    }
}
Need a hint?
Look at what is being asserted (method calls vs behavior), how many mocks are used, and what the test name tells you about what is being verified.
Show answer
Bug 1: The test mocks ALL dependencies — PaymentRepository, NotificationService, AuditLogger, PaymentValidator, AND CurrencyFormatter. This is a sign of over-mocking. If a class genuinely needs 5 mocked dependencies to function, the class itself may have too many responsibilities (violating Single Responsibility Principle). Consider using a Fake for the repository and only mocking the services where call verification matters. Alternatively, create a test data builder that sets up common state. Bug 2: useCase.process() is called with no assertion about its return value or observable effect. If process() returns a Result or emits a Flow item (the actual payment result — success or failure), the test is not checking the output that matters to the user. A payment test should verify 'did the payment succeed?' not just 'did a bunch of methods get called?' Bug 3: ALL assertions are coVerify checks — the test verifies that 5 internal methods were called. This is pure implementation testing, not behavior testing. If you refactor ProcessPaymentUseCase to combine validation and formatting into one step (calling a single validateAndFormat() method), all these tests break even if the payment logic is identical. Fix: assert on the USE CASE'S OUTPUT — the returned PaymentResult, emitted Flow state, or thrown exception. Only use coVerify for side effects that genuinely matter (like 'did we send the notification?') not for internal implementation steps. The test name 'testProcessPayment' is also too generic — rename it to 'successful payment with valid amount returns PaymentResult.Success' to document behavior.

Explain like I'm 5

Testing is like checking your homework before you hand it in. Unit tests are checking one math problem at a time — very fast. Instrumentation tests are like checking your whole homework page — takes longer. UI tests are like having your teacher watch you do your homework in real time — the most realistic but you can only do it sometimes. The smart student checks each problem first (unit tests) before worrying about the whole page.

Fun fact

Google's internal study of their own Android codebases found that teams with >60% unit test coverage shipped features 2x faster than teams with <20% coverage — not because writing tests is fast, but because debugging production issues and manually verifying regressions takes far more time than the tests saved.

Hands-on challenge

Map out a testing strategy for a SaaS mobile app with these features: user authentication, subscription management, dashboard with charts, offline data sync, and push notifications. For each feature, decide: (a) what would you unit test, (b) what would you integration/instrumentation test, (c) what UI journey (if any) deserves an end-to-end test. Write out your reasoning for each decision.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery