Android Testing and Gradle

Android Testing and Gradle

1. Testing Strategy and Pyramid

1.1 What is a Testing Strategy?

A testing strategy decides:

* What should be tested

* At which test level

* Which tool should be used

* How much confidence the test provides

* How quickly and reliably the test runs

The goal is not to test every line. The goal is to protect important application behaviour.

---

1.2 Testing Pyramid

flowchart BT
    U["Many Unit Tests<br/>Fast and cheap"]
    I["Fewer Integration Tests<br/>Medium speed"]
    UI["Small Number of UI Tests<br/>Slower"]
    E["Very Few End-to-End Tests<br/>Slow and expensive"]

    U --> I
    I --> UI
    UI --> E
LayerTestsSpeedMaintenanceConfidence
UnitOne class or functionVery fastLowFocused
IntegrationMultiple real componentsMediumMediumHigher
UIScreen behaviourSlowHighHigh for UI
End-to-endComplete user journeySlowestHighestHighest overall

A healthy test suite normally contains many fast unit tests and fewer complete UI journeys.

---

1.3 Unit Tests

A unit test checks one small part in isolation.

Examples:

* Validation

* UseCase

* ViewModel

* Mapper

* Price calculation

* Error mapping

* State transition

flowchart LR
    T["Unit Test"] --> C["One class"]
    C --> F["Fake dependencies"]

Example:

@Test
fun `empty email returns validation error`() {
    val validator = LoginValidator()

    val result = validator.validate(
        email = "",
        password = "123456"
    )

    assertEquals(
        LoginError.EmailRequired,
        result
    )
}

Unit tests should normally run inside src/test.

---

1.4 Integration Tests

Integration tests verify that multiple components work together.

Examples:

* Repository with API mapper

* Repository with Room database

* DAO with a real in-memory database

* Retrofit with MockWebServer

* ViewModel with a real UseCase

* JSON response with DTO parser

flowchart LR
    T["Integration Test"] --> R["Repository"]
    R --> A["Fake API Server"]
    R --> DB["In-memory Database"]

Integration tests catch problems that isolated unit tests cannot detect, such as incorrect SQL, JSON fields or mappings.

---

1.5 UI Tests

UI tests verify what the user sees and does.

Examples:

* Enter email and password

* Tap Login

* Verify loading indicator

* Verify error message

* Open product details

* Confirm payment

flowchart LR
    A["Find UI element"] --> B["Perform action"]
    B --> C["Assert result"]

UI tests can use:

* Compose testing APIs

* Espresso

* ActivityScenario

* Instrumented device or emulator

---

1.6 End-to-End Tests

An end-to-end test validates a complete journey.

Example banking journey:

flowchart LR
    L["Login"] --> B["Select beneficiary"]
    B --> P["Enter payment"]
    P --> C["Confirm"]
    C --> S["Success"]

E2E tests may involve:

* Multiple screens

* Navigation

* Database

* Network environment

* Authentication

* Backend services

They provide high confidence but are slower and more fragile. Keep only critical journeys as E2E tests.

---

1.7 What Should Be Tested?

Prioritize:

* Business-critical rules

* Payment and authentication flows

* Complex calculations

* Error handling

* State transitions

* Data mapping

* Security-related behaviour

* Previously reported bugs

* Code that changes frequently

Avoid testing:

* Kotlin or Android framework behaviour

* Simple getters and setters

* Private implementation details

* Exact internal method-call order unless it is the requirement

* Third-party libraries themselves

Decision rule

flowchart TD
    B{"Important behaviour?"}
    B -->|No| S["May skip"]
    B -->|Yes| P{"Pure logic?"}
    P -->|Yes| U["Unit test"]
    P -->|No| M{"Multiple components?"}
    M -->|Yes| I["Integration test"]
    M -->|No| V{"User-visible flow?"}
    V -->|Yes| UI["UI or E2E test"]

> Test behaviour at the lowest level that can provide enough confidence.

---

2. Unit, Integration and UI Testing

2.1 JUnit

JUnit provides the basic test structure and assertions.

class CalculatorTest {

    @Test
    fun `two numbers are added correctly`() {
        val result = Calculator.add(2, 3)

        assertEquals(5, result)
    }
}

Common annotations:

@Test
@Before
@After
@BeforeClass
@AfterClass

Use descriptive names:

@Test
fun `login fails when password is empty`() { }

---

2.2 Fakes and Mocks

Fake

A fake is a working lightweight implementation.

class FakeUserRepository : UserRepository {

    var result: Result<User> =
        Result.success(User("1", "Mahesh"))

    override suspend fun login(
        email: String,
        password: String
    ): Result<User> {
        return result
    }
}

Mock

A mock verifies interactions.

coVerify {
    repository.login(
        "user@example.com",
        "123456"
    )
}
FakeMock
Working test implementationCreated using mocking framework
Stores data or returns configured resultVerifies calls and arguments
Easy to understandUseful for interaction testing
Less tied to implementationCan become implementation-dependent

Prefer fakes for State-based tests. Use mocks when an interaction itself is important.

---

2.3 ViewModel Test

Test the ViewModel’s observable behaviour:

Action → Loading → Success

or:

Action → Loading → Error
@Test
fun `load products emits success state`() = runTest {
    val repository = FakeProductRepository()
    val viewModel = ProductViewModel(repository)

    viewModel.loadProducts()
    advanceUntilIdle()

    assertFalse(viewModel.uiState.value.isLoading)
    assertEquals(
        repository.products,
        viewModel.uiState.value.products
    )
}

Do not test private functions directly. Test them through public actions and observable State.

---

2.4 Repository Tests

A repository test verifies:

* Correct data source is called

* DTO maps correctly to domain model

* Cache behaviour works

* Errors map correctly

* API data is stored in database

* Offline fallback works

@Test
fun `repository maps API product correctly`() = runTest {
    val api = FakeProductApi(
        ProductDto(
            id = "10",
            title = "Phone"
        )
    )

    val repository = ProductRepositoryImpl(api)

    val product = repository.getProduct("10")

    assertEquals("10", product.id)
    assertEquals("Phone", product.name)
}

---

2.5 Database Tests

Use an in-memory Room database for DAO integration tests.

@Before
fun setup() {
    database = Room.inMemoryDatabaseBuilder(
        ApplicationProvider.getApplicationContext(),
        AppDatabase::class.java
    )
        .allowMainThreadQueries()
        .build()

    dao = database.productDao()
}

@After
fun tearDown() {
    database.close()
}

Test:

@Test
fun insertProduct_thenReadProduct() = runTest {
    val entity = ProductEntity(
        id = "1",
        name = "Phone"
    )

    dao.insert(entity)

    assertEquals(
        entity,
        dao.getById("1")
    )
}

Database tests should use an actual Room implementation rather than mocking the DAO when SQL behaviour is the target.

---

2.6 API Tests

Use MockWebServer to simulate an HTTP server.

mockWebServer.enqueue(
    MockResponse()
        .setResponseCode(200)
        .setBody(
            """
            {
              "id": "1",
              "title": "Phone"
            }
            """.trimIndent()
        )
)

Test:

@Test
fun productApi_parsesSuccessResponse() = runTest {
    val product = api.getProduct("1")

    assertEquals("1", product.id)
    assertEquals("Phone", product.title)

    val request = mockWebServer.takeRequest()
    assertEquals("/products/1", request.path)
}

Test important cases:

* 200 success

* 400 validation error

* 401 unauthorized

* 404 not found

* 500 server failure

* Malformed JSON

* Empty response

* Timeout

---

2.7 Contract Tests

A contract test confirms that two systems agree on the request and response format.

For example:

Android app expects:
{
  "id": "1",
  "amount": 500.0,
  "status": "SUCCESS"
}

The test verifies:

* Field names

* Required and optional values

* Data types

* Status values

* Error body format

* Request headers

* API version expectations

Contract tests protect against backend changes that break the mobile application even when each system passes its own unit tests.

---

3. Coroutine and Flow Testing

3.1 runTest

Use runTest instead of runBlocking for coroutine tests.

@Test
fun loadData_returnsResult() = runTest {
    val result = repository.loadData()

    assertEquals(expected, result)
}

runTest:

* Creates a test coroutine scope

* Skips supported delays using virtual time

* Waits for child coroutines

* Reports uncaught coroutine exceptions

* Supports deterministic scheduling

Official Kotlin coroutine-test documentation

---

3.2 Test Dispatchers

StandardTestDispatcher

Queues tasks until the scheduler is advanced.

val dispatcher = StandardTestDispatcher(testScheduler)

It gives precise control over execution order.

UnconfinedTestDispatcher

Starts new coroutines eagerly.

val dispatcher =
    UnconfinedTestDispatcher(testScheduler)

It is convenient for some Flow collection tests but does not behave exactly like a normal production dispatcher.

DispatcherBehaviourBest use
StandardTestDispatcherQueues workPrecise scheduling
UnconfinedTestDispatcherStarts eagerlySimple collectors

All test dispatchers in one test should share the same TestCoroutineScheduler.

---

3.3 Main Dispatcher Rule

ViewModels often use Dispatchers.Main. Replace it during tests.

class MainDispatcherRule(
    val dispatcher: TestDispatcher =
        StandardTestDispatcher()
) : TestWatcher() {

    override fun starting(
        description: Description
    ) {
        Dispatchers.setMain(dispatcher)
    }

    override fun finished(
        description: Description
    ) {
        Dispatchers.resetMain()
    }
}

Usage:

@get:Rule
val mainDispatcherRule = MainDispatcherRule()

Better application design also injects dispatchers instead of hard-coding them.

---

3.4 Virtual Time

Virtual time tests delays without waiting in real time.

@Test
fun retry_waits_before_second_attempt() = runTest {
    service.load()

    advanceTimeBy(1_000)
    runCurrent()

    assertEquals(2, service.attemptCount)
}

Important functions:

| Function              | Purpose                                   |
| --------------------- | ----------------------------------------- |
| `runCurrent()`        | Run tasks scheduled at the current time   |
| `advanceTimeBy(1000)` | Move virtual clock forward                |
| `advanceUntilIdle()`  | Run tasks until no scheduled work remains |
| `currentTime`         | Read current virtual time                 |
flowchart LR
    T0["0 ms"] -->|advanceTimeBy| T1["1000 ms"]
    T1 --> R["Run scheduled retry"]

---

3.5 StateFlow Testing

A StateFlow always has a current value.

Test final value

@Test
fun login_success_updatesState() = runTest {
    viewModel.login()
    advanceUntilIdle()

    assertEquals(
        LoginUiState.Success,
        viewModel.uiState.value
    )
}

Test every emission with Turbine

@Test
fun login_emitsLoadingThenSuccess() = runTest {
    viewModel.uiState.test {
        assertEquals(LoginUiState.Idle, awaitItem())

        viewModel.login()

        assertEquals(LoginUiState.Loading, awaitItem())
        assertEquals(
            LoginUiState.Success,
            awaitItem()
        )

        cancelAndIgnoreRemainingEvents()
    }
}

Use the current value when only the final State matters. Use Turbine when emission order matters.

---

3.6 SharedFlow Testing

SharedFlow normally does not retain an event unless replay is configured. Start collection before emitting.

@Test
fun paymentSuccess_emitsNavigationEvent() = runTest {
    backgroundScope.launch(
        UnconfinedTestDispatcher(testScheduler)
    ) {
        viewModel.events.collect { event ->
            receivedEvents += event
        }
    }

    viewModel.confirmPayment()
    advanceUntilIdle()

    assertEquals(
        PaymentEvent.OpenSuccess,
        receivedEvents.first()
    )
}

With Turbine:

viewModel.events.test {
    viewModel.confirmPayment()

    assertEquals(
        PaymentEvent.OpenSuccess,
        awaitItem()
    )
}

---

3.7 Cancellation Testing

@Test
fun search_cancelsPreviousRequest() = runTest {
    viewModel.search("phone")
    advanceTimeBy(200)

    viewModel.search("laptop")
    advanceUntilIdle()

    assertEquals(
        "laptop",
        viewModel.uiState.value.query
    )
}

Verify:

* Old request is cancelled

* Old result does not overwrite the new result

* Cancellation is not incorrectly converted into a normal error

Avoid catching and hiding CancellationException:

catch (error: CancellationException) {
    throw error
}

---

3.8 Retry Testing

@Test
fun request_retriesThreeTimes() = runTest {
    fakeApi.failuresBeforeSuccess = 2

    val result = repository.loadWithRetry()

    advanceUntilIdle()

    assertTrue(result.isSuccess)
    assertEquals(3, fakeApi.requestCount)
}

Verify:

* Maximum attempt count

* Delay or backoff

* Success after retry

* Final failure

* Non-retryable errors do not retry

* Cancellation stops retrying

---

3.9 Deterministic Async Tests

A deterministic test produces the same result every time.

Use:

* Test dispatchers

* Virtual time

* Fake APIs

* Fixed input data

* Controlled clocks

* Fixed random values

* No real network

* No Thread.sleep()

flowchart LR
    I["Controlled input"] --> T["Test"]
    T --> O["Predictable output"]

---

4. Robolectric, Espresso and Compose Testing

4.1 JVM vs Instrumented Tests

TypeLocationRuns onSpeed
Local JVM testsrc/testJVMFast
Robolectric testsrc/testJVM with simulated AndroidFast–medium
Instrumented testsrc/androidTestDevice/emulatorSlow
Compose UI testUsually src/androidTestDevice/emulatorSlow

---

4.2 Robolectric

Robolectric simulates Android framework behaviour on the JVM.

Use it for:

* Activity or View tests without an emulator

* Resource access

* Lifecycle-related JVM tests

* Code requiring Android framework classes

* Faster feedback than instrumentation

@RunWith(RobolectricTestRunner::class)
class MainActivityTest {

    @Test
    fun activity_showsTitle() {
        val activity =
            Robolectric.buildActivity(
                MainActivity::class.java
            )
                .setup()
                .get()

        assertEquals(
            "Home",
            activity.title
        )
    }
}

Limitations:

* It is not a real device.

* Some system behaviour differs.

* Hardware, OEM and rendering behaviour need instrumented tests.

---

4.3 Espresso

Espresso tests traditional Android View-based UI on a device or emulator.

onView(withId(R.id.email))
    .perform(typeText("user@example.com"))

onView(withId(R.id.login))
    .perform(click())

onView(withText("Welcome"))
    .check(matches(isDisplayed()))

Use Espresso for:

* XML layouts

* View-based screens

* Hybrid Compose/View applications

* Existing legacy UI test suites

---

4.4 ActivityScenario

ActivityScenario launches an Activity in tests and controls its lifecycle.

val scenario =
    ActivityScenario.launch(MainActivity::class.java)

scenario.moveToState(
    Lifecycle.State.CREATED
)

scenario.recreate()

scenario.close()

Use it to test:

* Activity launch

* Lifecycle transitions

* Recreation

* Configuration-related behaviour

* View-based screen integration

Prefer ActivityScenario over manually calling Activity lifecycle methods.

---

4.5 Idling Resources

Espresso automatically waits for the main thread and known framework work to become idle.

For asynchronous work Espresso cannot observe, register an `IdlingResource`.
flowchart LR
    T["Test"] --> C{"Application idle?"}
    C -->|No| W["Wait"]
    C -->|Yes| A["Perform assertion"]
Avoid using `Thread.sleep()`. It is either:

* Too short and flaky

* Too long and slow

Design code for observable State and inject fakes when possible. Use custom idling resources only when necessary.

---

4.6 Compose Testing APIs

@get:Rule
val composeRule = createComposeRule()

Example:

@Test
fun loginButton_click_showsWelcome() {
    composeRule.setContent {
        LoginScreen()
    }

    composeRule
        .onNodeWithText("Login")
        .performClick()

    composeRule
        .onNodeWithText("Welcome")
        .assertIsDisplayed()
}

Compose tests follow:

Find node → Perform action → Assert result

---

4.7 Semantics

Compose tests interact with the semantics tree.

Find nodes using:

onNodeWithText("Login")
onNodeWithContentDescription("Delete")
onNodeWithTag("payment_button")
onNode(hasClickAction())

Actions:

performClick()
performTextInput("Mahesh")
performScrollTo()
performTouchInput {
    swipeUp()
}

Assertions:

assertExists()
assertIsDisplayed()
assertIsEnabled()
assertIsSelected()
assertTextEquals("Success")

Good accessibility semantics also make UI testing easier.

---

4.8 Framework Selection

flowchart TD
    Q{"What are you testing?"}
    Q -->|Pure Kotlin logic| J["JUnit"]
    Q -->|Android API on JVM| R["Robolectric"]
    Q -->|Traditional View UI| E["Espresso"]
    Q -->|Compose UI| C["Compose testing"]
    Q -->|Complete real-device behaviour| I["Instrumentation"]
RequirementRecommended tool
Pure calculationJUnit
ViewModelJUnit + coroutine test
DAORoom in-memory test
Retrofit parsingMockWebServer
Android framework on JVMRobolectric
XML screenEspresso
Compose screenCompose testing APIs
Mixed UIEspresso + Compose interoperability
Hardware/system integrationInstrumented test

---

5. Test Flakiness and Reliability

5.1 What is a Flaky Test?

A flaky test sometimes passes and sometimes fails without a relevant code change.

flowchart LR
    C["Same code"] --> R1["Run 1: Pass"]
    C --> R2["Run 2: Fail"]
    C --> R3["Run 3: Pass"]

Flaky tests reduce confidence in the complete test suite.

---

5.2 Common Causes

Timing problems

Thread.sleep(1_000)

The operation may take 900 ms on one device and 1,500 ms on another.

Shared State

Tests modify:

* Singleton objects

* Shared database

* Static fields

* Files

* Preferences

* Fake server queues

One test affects another.

Real network dependency

Failures can result from:

* Slow network

* DNS

* Server downtime

* Rate limits

* Changed data

Animations

The test asserts while a transition is still running.

Incorrect test order dependency

Test B passes only if Test A runs first.

Uncontrolled asynchronous work

Background coroutines continue after the test ends.

---

5.3 Test Isolation

Every test should create and clean up its own State.

@Before
fun setup() {
    repository = FakeRepository()
    database = createInMemoryDatabase()
}

@After
fun tearDown() {
    database.close()
}

Tests should work:

* Individually

* In any order

* In parallel when supported

* Repeatedly

* On local and CI environments

---

5.4 Deterministic Data

Avoid:

val userId = Random.nextInt()
val time = System.currentTimeMillis()

Inject controlled dependencies:

interface Clock {
    fun now(): Instant
}

class FakeClock(
    private val instant: Instant
) : Clock {
    override fun now() = instant
}

Use fixed:

* Dates

* Time zones

* Locales

* IDs

* API responses

* Random seeds

* Database contents

---

5.5 Synchronization

Wait for a condition, not an estimated duration.

Compose

composeRule.waitUntil(timeoutMillis = 5_000) {
    composeRule
        .onAllNodesWithText("Success")
        .fetchSemanticsNodes()
        .isNotEmpty()
}

Coroutines

advanceUntilIdle()

Espresso

Use framework synchronization or an appropriate idling resource.

---

5.6 Animation Control

For Compose tests:

composeRule.mainClock.autoAdvance = false

composeRule.mainClock.advanceTimeBy(500)

Or disable animations in suitable test environments.

Test the final UI State rather than relying on intermediate frame timing unless animation behaviour itself is the requirement.

---

5.7 Retries

Retries may reduce temporary CI noise, but they do not fix a flaky test.

Bad approach:

Test failed → Retry until it passes → Ignore cause

Better approach:

flowchart LR
    F["Flaky failure"] --> C["Collect evidence"]
    C --> R["Find root cause"]
    R --> X["Fix synchronization or isolation"]

A retry can be used temporarily while:

* Recording failure rate

* Collecting logs

* Assigning an owner

* Working on the fix

---

5.8 Flaky-Test Monitoring

Track:

* Test name

* Failure frequency

* Device/API level

* CI worker

* Failure message

* Screenshot

* Video

* Logcat

* Duration

* Retry result

* First failure date

* Assigned owner

Do not silently delete or permanently ignore flaky tests. Quarantine only when necessary and keep visibility until repaired.

---

6. Gradle Internals and Build Performance

6.1 Gradle Build Lifecycle

Gradle has three main phases:

flowchart LR
    I["1. Initialization"] --> C["2. Configuration"]
    C --> E["3. Execution"]

Gradle: Build Lifecycle

---

6.2 Initialization Phase

Gradle determines which projects participate in the build.

It processes:

* Init scripts

* settings.gradle.kts

* Root project

* Included modules

* Included builds

Example:

// settings.gradle.kts

rootProject.name = "BankingApp"

include(":app")
include(":feature:payment")
include(":core:network")

Result:

Gradle knows the build structure.

---

6.3 Configuration Phase

Gradle evaluates build scripts and registers tasks.

It:

* Applies plugins

* Reads build configuration

* Registers tasks

* Configures variants

* Resolves task relationships

* Creates the task graph for requested work

tasks.register("printMessage") {
    doLast {
        println("Hello")
    }
}

Use lazy task registration:

tasks.register("taskName")

Avoid eagerly creating tasks:

tasks.create("taskName")

Heavy work should not run during configuration.

---

6.4 Execution Phase

Gradle runs only the selected tasks and their dependencies.

Command:

./gradlew assembleDebug

Possible task flow:

flowchart TD
    A["assembleDebug"] --> P["packageDebug"]
    P --> K["compileDebugKotlin"]
    P --> R["processDebugResources"]
    K --> D["Required dependencies"]

Gradle uses the task graph to determine:

* Which tasks must run

* Task order

* Which tasks can run in parallel

* Which tasks are up-to-date

* Which outputs can come from cache

---

6.5 Task Graph

A task graph represents task dependencies.

flowchart TD
    A["assembleDebug"] --> P["packageDebug"]
    P --> K["compileKotlin"]
    P --> R["processResources"]
    K --> G["generateSources"]

Gradle does not normally run every task in the project. It runs the requested tasks and required dependencies.

Inspect tasks:

./gradlew tasks

Dry run:

./gradlew assembleDebug --dry-run

---

6.6 Incremental Build and Up-to-Date Checks

Gradle tracks task inputs and outputs.

flowchart TD
    T["Task requested"] --> C{"Inputs changed?"}
    C -->|No| S["UP-TO-DATE: skip task"]
    C -->|Yes| R["Execute task"]
    R --> O["Store outputs"]

Example custom task:

abstract class GenerateReportTask : DefaultTask() {

    @get:InputFile
    abstract val inputFile:
        RegularFileProperty

    @get:OutputFile
    abstract val outputFile:
        RegularFileProperty

    @TaskAction
    fun generate() {
        // Generate output
    }
}

Correct input/output declarations allow Gradle to avoid unnecessary work.

---

6.7 Build Cache

The build cache stores task outputs for a particular set of inputs.

flowchart LR
    I["Task inputs"] --> K["Cache key"]
    K --> C{"Output available?"}
    C -->|Yes| H["FROM-CACHE"]
    C -->|No| E["Execute task and cache output"]

Enable:

org.gradle.caching=true

Types:

* Local cache: Same developer machine

* Remote cache: Shared across developers and CI

Up-to-date vs build cache

Up-to-date checkBuild cache
Reuses output already in current workspaceRestores output from cache
Same local build directoryLocal or remote cache
Output remains presentOutput may have been deleted

Gradle: Build performance

---

6.8 Configuration Cache

Configuration cache stores the result of the configuration phase.

flowchart TD
    B["Gradle command"] --> C{"Matching configuration entry?"}
    C -->|No| F["Run configuration and save it"]
    C -->|Yes| S["Skip configuration"]
    F --> E["Execute tasks"]
    S --> E

Enable:

org.gradle.configuration-cache=true

Build Cache vs Configuration Cache

Build CacheConfiguration Cache
Caches task outputsCaches configured task graph
Saves execution workSaves configuration work
Keyed by task inputsKeyed by build configuration inputs
Returns compiled/generated outputSkips project configuration

Plugins and custom tasks must be compatible with configuration cache rules. Gradle: Configuration Cache

---

6.9 Parallelism

Independent tasks can run at the same time.

Enable:

org.gradle.parallel=true
flowchart TD
    A["Build"] --> T1["Module A task"]
    A --> T2["Module B task"]
    A --> T3["Module C task"]

Parallelism works best when:

* The project has multiple modules

* Tasks are independent

* The machine has sufficient CPU and memory

* Tasks do not share unsafe mutable resources

Too much parallelism on a memory-limited CI worker may reduce performance.

---

6.10 Build Profiling

Measure before optimizing.

Local profile report

./gradlew assembleDebug --profile

This generates an HTML report containing:

* Configuration time

* Task execution time

* Dependency resolution

* Slow tasks

Build Scan

./gradlew assembleDebug --scan

A Build Scan can show:

* Build timeline

* Cache misses

* Slow plugins

* Task execution

* Dependency resolution

* Critical path

Remember that publishing a Build Scan sends collected build information to the configured external service, so follow company policy.

Gradle: Inspect and profile builds

---

6.11 Build Performance Checklist

* Use a supported Gradle daemon setup.

* Use lazy task registration.

* Avoid work during configuration.

* Enable build cache.

* Test configuration cache compatibility.

* Declare task inputs and outputs.

* Use incremental annotation processing where available.

* Remove unnecessary plugins.

* Avoid dynamic dependency versions.

* Keep build logic maintainable.

* Use parallel execution when beneficial.

* Modularize based on real build and ownership boundaries.

* Profile before changing build configuration.

* Compare clean builds and incremental builds separately.

---

Common Interview Questions

Q1. What is the testing pyramid?

It recommends many fast unit tests, fewer integration tests, a smaller number of UI tests and very few complete E2E tests.

Q2. What is the difference between a fake and a mock?

A fake is a lightweight working implementation. A mock is usually configured to return values and verify interactions.

Q3. What should be tested in a ViewModel?

Test user actions, State transitions, validation, loading, success, error, cancellation and one-time events.

Q4. Why use runTest?

It provides a test scope, virtual time, controlled scheduling and proper coroutine exception handling.

Q5. What is the difference between StandardTestDispatcher and UnconfinedTestDispatcher?

StandardTestDispatcher queues work for controlled execution. UnconfinedTestDispatcher starts work eagerly and is convenient for some collectors but has unusual ordering behaviour.

Q6. How do you test a StateFlow?

Assert its current value when only final State matters, or collect it with Turbine when the emission sequence matters.

Q7. Why can SharedFlow tests miss events?

A SharedFlow may have no replay. The collector must start before the event is emitted.

Q8. When should Robolectric be used?

Use it when Android framework behaviour is needed but a fast JVM test provides enough confidence.

Q9. Espresso vs Compose testing?

Espresso primarily interacts with traditional Views. Compose tests interact with the Compose semantics tree.

Q10. What causes flaky tests?

Common causes include timing assumptions, shared State, real network calls, uncontrolled asynchronous work, animations and test-order dependencies.

Q11. Are retries a solution for flaky tests?

No. They can temporarily collect evidence, but synchronization, isolation or dependency problems must be fixed.

Q12. What are Gradle’s build phases?

Initialization identifies projects, configuration creates the task graph, and execution runs selected tasks.

Q13. Build cache vs configuration cache?

Build cache stores task outputs. Configuration cache stores the result of the configuration phase and configured task graph.

Q14. What is an incremental build?

Gradle uses declared inputs and outputs to skip unchanged tasks or process only changed inputs.

Q15. How do you diagnose a slow Gradle build?

Use --profile, Build Scans and task timelines to find slow configuration, plugins, dependency resolution, tasks and cache misses.

---

Final Interview Summary

> I follow a testing pyramid with many unit tests, targeted integration tests, fewer UI tests and only critical end-to-end journeys. Unit tests cover business logic and ViewModel State, integration tests verify repositories, Room and API parsing, and UI tests verify user-visible behaviour. Coroutine tests use runTest, shared test dispatchers and virtual time so delays, retries, Flow emissions and cancellation remain deterministic. I choose JUnit for pure logic, Robolectric for Android-dependent JVM tests, Espresso for View screens and Compose test APIs for Compose semantics. Flakiness is controlled through isolation, fixed data, fake dependencies and proper synchronization. Gradle builds run through initialization, configuration and execution, while incremental builds, build cache, configuration cache and parallelism avoid unnecessary work.