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| Layer | Tests | Speed | Maintenance | Confidence |
|---|---|---|---|---|
| Unit | One class or function | Very fast | Low | Focused |
| Integration | Multiple real components | Medium | Medium | Higher |
| UI | Screen behaviour | Slow | High | High for UI |
| End-to-end | Complete user journey | Slowest | Highest | Highest 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
@AfterClassUse 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"
)
}| Fake | Mock |
|---|---|
| Working test implementation | Created using mocking framework |
| Stores data or returns configured result | Verifies calls and arguments |
| Easy to understand | Useful for interaction testing |
| Less tied to implementation | Can 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 → Successor:
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.
| Dispatcher | Behaviour | Best use |
|---|---|---|
StandardTestDispatcher | Queues work | Precise scheduling |
UnconfinedTestDispatcher | Starts eagerly | Simple 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
| Type | Location | Runs on | Speed |
|---|---|---|---|
| Local JVM test | src/test | JVM | Fast |
| Robolectric test | src/test | JVM with simulated Android | Fast–medium |
| Instrumented test | src/androidTest | Device/emulator | Slow |
| Compose UI test | Usually src/androidTest | Device/emulator | Slow |
---
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"]| Requirement | Recommended tool |
|---|---|
| Pure calculation | JUnit |
| ViewModel | JUnit + coroutine test |
| DAO | Room in-memory test |
| Retrofit parsing | MockWebServer |
| Android framework on JVM | Robolectric |
| XML screen | Espresso |
| Compose screen | Compose testing APIs |
| Mixed UI | Espresso + Compose interoperability |
| Hardware/system integration | Instrumented 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 causeBetter 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"]---
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 assembleDebugPossible 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 tasksDry 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=trueTypes:
* Local cache: Same developer machine
* Remote cache: Shared across developers and CI
Up-to-date vs build cache
| Up-to-date check | Build cache |
|---|---|
| Reuses output already in current workspace | Restores output from cache |
| Same local build directory | Local or remote cache |
| Output remains present | Output may have been deleted |
---
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 --> EEnable:
org.gradle.configuration-cache=trueBuild Cache vs Configuration Cache
| Build Cache | Configuration Cache |
|---|---|
| Caches task outputs | Caches configured task graph |
| Saves execution work | Saves configuration work |
| Keyed by task inputs | Keyed by build configuration inputs |
| Returns compiled/generated output | Skips 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=trueflowchart 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 --profileThis generates an HTML report containing:
* Configuration time
* Task execution time
* Dependency resolution
* Slow tasks
Build Scan
./gradlew assembleDebug --scanA 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.