Learn Android unit testing from beginner to interview level using JUnit, local JVM tests, assertions, lifecycle annotations, parameterized tests, fakes, stubs, mocks, spies, MockK/Mockito, dependency injection, interaction verification and behaviour-focused testing.
1. What Is Unit Testing?
Unit testing verifies a small piece of code in isolation.
A unit can be:
- Pure Kotlin function
- Validator
- Mapper
- Use case
- ViewModel
- Reducer
- Repository method with controlled dependencies
Example
class DiscountCalculator {
fun calculate(price: Double, discount: Int): Double {
return price - (price * discount / 100)
}
}class DiscountCalculatorTest {
@Test
fun calculate_withTenPercentDiscount_returnsDiscountedPrice() {
val calculator = DiscountCalculator()
val result = calculator.calculate(
price = 1_000.0,
discount = 10
)
assertEquals(900.0, result, 0.001)
}
}A good unit test should be:
- Fast
- Isolated
- Repeatable
- Deterministic
- Easy to understand
- Independent of other tests
2. Benefits and Limitations of Unit Tests
Benefits
- Execute quickly
- Find defects early
- Provide fast developer feedback
- Make refactoring safer
- Document expected behaviour
- Test edge and error conditions
- Run without emulator or device
- Integrate easily with CI
- Help improve code design
Limitations
- Cannot prove complete app behaviour
- Cannot fully verify Android framework behaviour
- Cannot detect every UI problem
- Cannot prove that real components integrate correctly
- Poorly designed tests can become difficult to maintain
- Excessive mocking can provide false confidence
Example
A ViewModel unit test can verify that a successful repository response produces Success state.
It cannot prove that:
- Retrofit calls the correct endpoint
- Room stores the entity correctly
- Compose displays the state correctly
- The complete screen navigation works
Those require integration or UI tests.
3. Local JVM Tests
Local tests run on the computer’s Java Virtual Machine instead of an Android device or emulator.
flowchart LR
S["Kotlin production code"]
T["JUnit test"]
JVM["Local JVM"]
R["Fast result"]
S --> T
T --> JVM
JVM --> RBest candidates
- Business rules
- Calculations
- String manipulation
- Validation
- Mapping
- Use cases
- ViewModel state logic
- Coroutine and Flow logic
Limitation
The local JVM does not provide the real Android framework.
Code that directly depends on the following may require redesign, mocking, Robolectric or instrumentation:Context
- Activity
- Fragment
- Resources
- SQLite
- Android services
- Android UI
4. The src/test Directory
Local JVM tests are placed under:
app/
└── src/
├── main/
│ └── java/
│ └── LoginValidator.kt
├── test/
│ └── java/
│ └── LoginValidatorTest.kt
└── androidTest/
└── java/
└── LoginScreenTest.kt| Directory | Purpose | Execution |
|---|---|---|
src/main | Production code | Application |
src/test | Local unit tests | JVM |
src/androidTest | Instrumented tests | Device/emulator |
Gradle dependencies
dependencies {
testImplementation("junit:junit:<version>")
testImplementation("io.mockk:mockk:<version>")
}Use your project’s version catalog when available:
dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
}5. JUnit Fundamentals
JUnit provides:
- Test discovery
- Test annotations
- Assertions
- Setup and cleanup
- Test runners
- Parameterized tests
- Test reports
Basic structure
class CalculatorTest {
@Test
fun add_twoNumbers_returnsSum() {
val calculator = Calculator()
val result = calculator.add(10, 20)
assertEquals(30, result)
}
}Test lifecycle
flowchart TD
BC["BeforeClass<br/>Once before class"]
B["Before<br/>Before each test"]
T["Test method"]
A["After<br/>After each test"]
AC["AfterClass<br/>Once after class"]
BC --> B --> T --> A
A -->|Next test| B
A -->|All tests complete| AC6. Test Class and Test Function
A test class usually corresponds to one production class.
class LoginValidatorTest {
@Test
fun validEmailAndPassword_returnsTrue() {
// Test
}
@Test
fun emptyPassword_returnsFalse() {
// Test
}
}Naming recommendations
Production class:
LoginValidatorTest class:
LoginValidatorTestTest function:
method_condition_expectedResultExample:
@Test
fun validate_passwordIsEmpty_returnsFalse()Kotlin backtick naming is also readable:
@Test
fun `empty password returns false`() {
// Test
}Tests should not depend on execution order.
7. The @Test Annotation
@Test tells JUnit that a function is a test case.
@Test
fun twoPlusThree_returnsFive() {
assertEquals(5, 2 + 3)
}Without @Test, JUnit normally does not execute the function as a test.
A JUnit 4 test function should generally:
- Be public from the JVM perspective
- Take no parameters, except with a specialized runner
- Return
Unit - Contain meaningful assertions
8. @Before and @After
@Before
Runs before every test function.
Use it to create fresh dependencies and the subject under test.
@After
Runs after every test function.
Use it to close or clear resources.
class UserRepositoryTest {
private lateinit var repository: FakeUserRepository
@Before
fun setUp() {
repository = FakeUserRepository()
}
@After
fun tearDown() {
repository.clear()
}
@Test
fun addUser_userCanBeRetrieved() {
repository.add(User(1, "Mahesh"))
assertEquals(
User(1, "Mahesh"),
repository.get(1)
)
}
}A new test class instance is normally created for each JUnit 4 test, but setup methods still make initialization clear.
9. @BeforeClass and @AfterClass
These run once for the complete test class.
@BeforeClass: before all tests@AfterClass: after all tests
In Kotlin with JUnit 4, define them as @JvmStatic methods inside a companion object:
class ConfigurationTest {
companion object {
@JvmStatic
@BeforeClass
fun beforeAllTests() {
println("Run once before all tests")
}
@JvmStatic
@AfterClass
fun afterAllTests() {
println("Run once after all tests")
}
}
@Test
fun sampleTest() {
assertTrue(true)
}
}Use them carefully. Shared mutable objects can make tests dependent on each other.
In JUnit 5, the corresponding annotations are @BeforeAll and @AfterAll.
10. Assertions
Assertions compare actual results with expected results.
Common JUnit assertions
| Assertion | Purpose |
|---|---|
assertEquals(expected, actual) | Verifies that two values are equal. |
assertNotEquals(unexpected, actual) | Verifies that two values are different. |
assertTrue(condition) | Verifies that a condition evaluates to true. |
assertFalse(condition) | Verifies that a condition evaluates to false. |
assertNull(value) | Verifies that a value is null. |
assertNotNull(value) | Verifies that a value is not null. |
assertSame(expected, actual) | Verifies that both references point to the same object instance. |
assertNotSame(unexpected, actual) | Verifies that two references point to different object instances. |
assertArrayEquals(expected, actual) | Verifies that two arrays contain the same values in the same order. |
fail(message) | Explicitly marks the test as failed. |
Examples
assertEquals(5, result)
assertTrue(user.isActive)
assertFalse(result.isError)
assertNull(user.middleName)
assertNotNull(response.data)Floating-point comparison
Provide a delta because floating-point calculations may not be exact:
assertEquals(
10.5,
actualValue,
0.001
)Good assertion
assertEquals(
PaymentState.Success(transactionId = "TX123"),
viewModel.state.value
)Weak assertion
assertNotNull(viewModel.state.value)The weak assertion may pass even when the state is incorrect.
11. Exception Testing
Exception tests verify that invalid operations throw the expected exception.
JUnit 4 example
@Test(expected = IllegalArgumentException::class)
fun withdraw_negativeAmount_throwsException() {
account.withdraw(-100.0)
}This approach checks the exception type but not its message or detailed properties.
Using assertThrows
@Test
fun withdraw_negativeAmount_throwsException() {
val exception = assertThrows(
IllegalArgumentException::class.java
) {
account.withdraw(-100.0)
}
assertEquals(
"Amount must be positive",
exception.message
)
}Also verify important side effects:
assertEquals(5_000.0, account.balance, 0.001)The failed operation must not change the balance.
12. Parameterized Testing
Parameterized testing executes the same test with multiple input values.
Use case
Password rules:
- At least eight characters
- Not empty
- Contains required character types
Instead of writing one test for every input, use a parameterized test.
@RunWith(Parameterized::class)
class PasswordValidatorTest(
private val password: String,
private val expected: Boolean
) {
@Test
fun validate_returnsExpectedResult() {
assertEquals(
expected,
PasswordValidator.isValid(password)
)
}
companion object {
@JvmStatic
@Parameterized.Parameters(
name = "password={0}, expected={1}"
)
fun data(): Collection<Array<Any>> {
return listOf(
arrayOf("", false),
arrayOf("123", false),
arrayOf("Password1", true),
arrayOf("Android123", true)
)
}
}
}Use parameterized tests when:
- The test logic is identical
- Only input and expected output change
- Boundary values must be checked
- Validation contains multiple cases
Avoid parameterization when each scenario requires significantly different setup or assertions.
13. Testing Pure Kotlin Functions
A pure function:
- Produces the same output for the same input
- Has no external side effects
- Does not depend on global state
- Does not modify external objects
fun calculateTax(amount: Double, rate: Double): Double {
return amount * rate / 100
}@Test
fun calculateTax_validAmount_returnsTax() {
val result = calculateTax(
amount = 1_000.0,
rate = 18.0
)
assertEquals(180.0, result, 0.001)
}Pure functions are easy to test because they do not need mocks, Android framework classes or complex setup.
14. Testing Validation Logic
Validation tests should include:
- Valid input
- Empty input
- Invalid format
- Minimum boundary
- Maximum boundary
- Null value where applicable
- Special characters
- Whitespace
- Case sensitivity
Example validator
class AmountValidator {
fun validate(amount: Long): ValidationResult {
return when {
amount <= 0 ->
ValidationResult.Error("Amount must be positive")
amount > 100_000 ->
ValidationResult.Error("Maximum amount exceeded")
else ->
ValidationResult.Valid
}
}
}Tests
@Test
fun zeroAmount_returnsError() {
assertEquals(
ValidationResult.Error("Amount must be positive"),
validator.validate(0)
)
}
@Test
fun maximumAmount_returnsValid() {
assertEquals(
ValidationResult.Valid,
validator.validate(100_000)
)
}
@Test
fun amountAboveMaximum_returnsError() {
assertEquals(
ValidationResult.Error("Maximum amount exceeded"),
validator.validate(100_001)
)
}15. Testing Mappers
A mapper converts one model into another.
flowchart LR
DTO["API DTO"] --> M["Mapper"]
D["Domain model"] --> UI["UI model"]
M --> DMapper
data class UserDto(
val id: Long?,
val fullName: String?
)
data class User(
val id: Long,
val name: String
)
fun UserDto.toDomain(): User {
return User(
id = id ?: 0,
name = fullName.orEmpty()
)
}Tests
@Test
fun validDto_mapsToDomainModel() {
val dto = UserDto(
id = 10,
fullName = "Mahesh"
)
val result = dto.toDomain()
assertEquals(
User(id = 10, name = "Mahesh"),
result
)
}
@Test
fun nullDtoFields_mapToDefaults() {
val result = UserDto(
id = null,
fullName = null
).toDomain()
assertEquals(
User(id = 0, name = ""),
result
)
}Test field mapping, null handling, enum conversion, formatting and invalid server values.
16. Testing Use Cases
A use case normally contains a business operation.
Money-transfer use case
interface TransferRepository {
fun transfer(amount: Long): TransferResult
}
class TransferMoneyUseCase(
private val repository: TransferRepository
) {
operator fun invoke(
amount: Long,
availableBalance: Long
): TransferResult {
if (amount <= 0) {
return TransferResult.InvalidAmount
}
if (amount > availableBalance) {
return TransferResult.InsufficientBalance
}
return repository.transfer(amount)
}
}Important tests:
- Invalid amount does not call repository
- Insufficient balance does not call repository
- Valid amount calls repository
- Repository success is returned
- Repository failure is correctly mapped
- Maximum limit is enforced
Use fake or mock repositories to keep the use-case test isolated.
17. Test Doubles
A test double replaces a real dependency during a test.
flowchart LR
UC["Use case"]
RP["Repository interface"]
REAL["Real repository<br/>API + database"]
DOUBLE["Test double<br/>Controlled behaviour"]
UC --> RP
RP --> REAL
RP --> DOUBLETest doubles make tests:
- Faster
- Predictable
- Independent
- Easier to configure
- Able to simulate errors
Main types:
1. Dummy
2. Stub
3. Fake
4. Mock
5. Spy
18. Dummy Objects
A dummy is passed only because a method requires it. The test does not use its behaviour.
interface Analytics {
fun track(event: String)
}
object DummyAnalytics : Analytics {
override fun track(event: String) = Unit
}val useCase = LoginUseCase(
repository = fakeRepository,
analytics = DummyAnalytics
)Use a dummy when the dependency is irrelevant to the behaviour under test.
19. Stubs
A stub returns predefined data.
class SuccessfulLoginStub : LoginRepository {
override fun login(
email: String,
password: String
): LoginResult {
return LoginResult.Success(userId = "U101")
}
}@Test
fun repositoryReturnsSuccess_useCaseReturnsSuccess() {
val useCase = LoginUseCase(SuccessfulLoginStub())
val result = useCase.login(
"user@example.com",
"Password123"
)
assertEquals(
LoginResult.Success("U101"),
result
)
}A stub mainly answers:
What should this dependency return?
20. Fakes
A fake has a simplified but working implementation.
In-memory repository
class FakeTaskRepository : TaskRepository {
private val tasks = mutableListOf<Task>()
var shouldFail = false
override fun save(task: Task) {
if (shouldFail) {
throw IllegalStateException("Save failed")
}
tasks += task
}
override fun getTasks(): List<Task> {
return tasks.toList()
}
}Test
@Test
fun save_validTask_taskCanBeRetrieved() {
val repository = FakeTaskRepository()
repository.save(Task(1, "Learn JUnit"))
assertEquals(
listOf(Task(1, "Learn JUnit")),
repository.getTasks()
)
}A fake is useful when several tests need realistic stateful behaviour.
21. Mocks
A mock is configured by the test and records interactions.
MockK example
@Test
fun validTransfer_callsRepository() {
val repository = mockk<TransferRepository>()
every {
repository.transfer(1_000)
} returns TransferResult.Success("TX101")
val useCase = TransferMoneyUseCase(repository)
val result = useCase(
amount = 1_000,
availableBalance = 5_000
)
assertEquals(
TransferResult.Success("TX101"),
result
)
verify(exactly = 1) {
repository.transfer(1_000)
}
}Mocks are useful when the interaction itself matters.
Examples:
* Payment request must be sent only once
* Analytics event should be recorded
* Invalid input must not reach repository
* Cache should be cleared on logout
22. Spies
A spy wraps or partially replaces a real object while recording calls.
class AnalyticsService {
fun track(event: String) {
// Real implementation
}
}@Test
fun loginSuccess_tracksAnalyticsEvent() {
val analytics = spyk(AnalyticsService())
loginManager.onLoginSuccess(analytics)
verify {
analytics.track("login_success")
}
}Risk
Spies may execute real behaviour unexpectedly. Partial mocking can make tests difficult to understand.
Prefer an interface with a fake or mock when possible.
23. Fake vs Mock
| Point | Fake | Mock |
|---|---|---|
| Implementation | Simplified working implementation | Dynamically configured object |
| State | Often maintains real state | Usually configured per test |
| Main verification | Final state/output | Interactions |
| Readability | Often easier | Can become complex |
| Reusability | High | Usually test-specific |
| Example | In-memory repository | Mock repository |
| Best for | State-based testing | Interaction-based testing |
Fake example
assertEquals(expectedTasks, fakeRepository.getTasks())Mock example
verify(exactly = 1) {
repository.save(expectedTask)
}Prefer fakes for readable state-based tests. Use mocks when verifying an important interaction.
24. MockK and Mockito Fundamentals
Both libraries create and configure mocks.
Basic comparison
| Feature | MockK | Mockito |
|---|---|---|
| Kotlin-focused API | Yes | Java-originated |
| Typical stubbing | every { } returns | whenever(...).thenReturn() |
| Suspend function | coEvery | Supported with suitable Kotlin setup |
| Verification | verify | verify |
| Coroutine verification | coVerify | Regular verification patterns |
| Argument capture | slot | ArgumentCaptor |
MockK
val repository = mockk<UserRepository>()
every {
repository.getUser(1)
} returns User(1, "Mahesh")Mockito
val repository = mock<UserRepository>()
whenever(repository.getUser(1))
.thenReturn(User(1, "Mahesh"))Do not mix MockK and Mockito within the same test suite without a clear need. Select one consistent library.
25. Stubbing Return Values
Stubbing tells a dependency what to return.
MockK normal function
every {
repository.getBalance()
} returns 5_000LMockK suspend function
coEvery {
repository.loadProfile()
} returns Profile(id = "P101")Throwing an exception
coEvery {
repository.loadProfile()
} throws IOException("No network")Sequential results
every {
repository.getStatus()
} returnsMany listOf(
Status.Pending,
Status.Pending,
Status.Success
)Use sequential results to test polling and retry behaviour.
Avoid broad stubbing:
every { repository.anyMethod(any()) } returns resultPrefer inputs that represent the actual scenario.
26. Verifying Interactions
Verification checks whether a dependency received an expected call.
Exactly once
verify(exactly = 1) {
repository.save(task)
}Never called
verify(exactly = 0) {
repository.transfer(any())
}MockK also provides:
verify {
repository wasNot Called
}Suspend function
coVerify(exactly = 1) {
repository.loadTasks()
}Order verification
verifyOrder {
cache.clear()
session.logout()
}Verify order only when the order is a real requirement.
Valuable interaction test
If the user taps the payment button twice, the repository must still receive only one submission:coVerify(exactly = 1) {
repository.submitPayment(any())
}27. Argument Capturing
Argument capturing inspects an object passed to a dependency.
Use case
The UI provides an amount and beneficiary ID. The use case creates a request containing an idempotency key.
@Test
fun transfer_createsCorrectRequest() {
val requestSlot = slot<TransferRequest>()
every {
repository.transfer(capture(requestSlot))
} returns TransferResult.Success("TX101")
useCase.transfer(
beneficiaryId = "B101",
amount = 1_000
)
val request = requestSlot.captured
assertEquals("B101", request.beneficiaryId)
assertEquals(1_000, request.amount)
assertTrue(request.idempotencyKey.isNotBlank())
}Capture arguments when:
* The object is created inside the subject
* Important field values must be checked
* Direct equality matching is insufficient
Do not capture arguments only to repeat an interaction assertion that could be written directly.
28. Over-Mocking Problems
Over-mocking means replacing too many collaborators or verifying too many internal calls.
Fragile test
verifyOrder {
validator.validate(input)
mapper.map(input)
repository.save(any())
analytics.track(any())
}This test can fail after harmless refactoring.
Problems caused by over-mocking
- Tests become implementation-dependent
- Refactoring breaks many tests
- Tests become hard to read
- Setup becomes larger than production logic
- Mock behaviour may differ from real implementation
- False confidence increases
- Important integration problems remain undetected
Better approach
Verify the observable result:
assertEquals(
SaveResult.Success,
result
)Verify only critical interactions:
verify(exactly = 1) {
repository.save(expectedTask)
}Use integration tests to verify that real components communicate correctly.
29. Dependency Injection for Testability
Dependency injection means a class receives dependencies instead of creating them internally.
Hard-to-test design
class LoginUseCase {
private val repository = LoginRepositoryImpl()
fun login(email: String, password: String) =
repository.login(email, password)
}The test cannot easily replace the real repository.
Testable design
class LoginUseCase(
private val repository: LoginRepository
) {
fun login(email: String, password: String) =
repository.login(email, password)
}Production:
val useCase = LoginUseCase(
repository = LoginRepositoryImpl()
)Test:
val useCase = LoginUseCase(
repository = FakeLoginRepository()
)Dependencies worth injecting
- Repositories
- API services
- DAOs
- Coroutine dispatchers
- Clock/time provider
- ID generator
- Random-value generator
- Analytics
- File storage
- Network-status provider
Dependency injection is not only a framework such as Hilt. Constructor injection is the basic principle.
30. Testing Public Behaviour Instead of Private Methods
Private methods are implementation details.
class PriceCalculator {
fun finalPrice(price: Double, premium: Boolean): Double {
val discount = calculateDiscount(premium)
return price - discount
}
private fun calculateDiscount(premium: Boolean): Double {
return if (premium) 100.0 else 0.0
}
}Do not directly test:
calculateDiscount()Test it through the public API:
@Test
fun premiumUser_finalPriceIncludesDiscount() {
val calculator = PriceCalculator()
val result = calculator.finalPrice(
price = 1_000.0,
premium = true
)
assertEquals(900.0, result, 0.001)
}If private logic is difficult to test
It may indicate that the private method contains a separate responsibility.
Extract it into another class:
class DiscountPolicy {
fun discountFor(premium: Boolean): Double {
return if (premium) 100.0 else 0.0
}
}Now DiscountPolicy can be tested through its public API.
Complete Use Case: Login Unit Tests
Production flow
flowchart LR
UI["Login UI"]
UC["LoginUseCase"]
VR["LoginValidator"]
RP["LoginRepository"]
API["Authentication API"]
UI --> UC
UC --> VR
UC --> RP
RP --> APIUnit-test boundary
flowchart LR
INPUT["Email and password"]
REAL["Real LoginUseCase"]
VAL["Real validator"]
FAKE["Fake repository"]
RESULT["LoginResult"]
INPUT --> REAL
REAL --> VAL
REAL --> FAKE
REAL --> RESULTRecommended tests
| Scenario | Test Double | Verification |
|---|---|---|
| Empty email | Mock Repository | Verify validation error is returned and the repository is never called. |
| Invalid password | Mock Repository | Verify validation error is returned and the repository is never called. |
| Valid credentials | Stub/Fake Repository | Verify the login operation returns a successful result. |
| Repository failure | Stub/Mock Repository | Verify a network error is returned or handled correctly. |
| Repository returns unauthorized | Stub/Fake Repository | Verify an invalid credentials result is returned. |
| Login succeeds | Mock Analytics | Verify the success event is recorded exactly once. |
Common Mistakes
1. Testing several unrelated behaviours in one function
2. Sharing mutable state between tests
3. Depending on test execution order
4. Testing private methods directly
5. Mocking simple value objects
6. Mocking the class under test
7. Verifying every internal call
8. Writing tests without meaningful assertions
9. Using unclear test names
10. Calling real network or database from unit tests
11. Catching exceptions without asserting them
12. Using Thread.sleep()13. Reusing one mock with incorrect state across tests
14. Using lateinit without initialization
15. Making production methods public only for testing
16. Using mocks where a simple fake would be clearer
17. Testing only successful scenarios
18. Ignoring boundary values
Interview Questions and Answers
1. What is unit testing?
Unit testing verifies a small component in isolation using controlled inputs and dependencies.
2. What makes a good unit test?
It should be fast, isolated, deterministic, readable, repeatable and focused on one behaviour.
3. What are the main benefits of unit tests?
Fast feedback, safer refactoring, early defect detection, executable documentation and easy CI execution.
4. What are the limitations of unit tests?
They cannot fully verify component integration, real Android behaviour, UI rendering or complete user journeys.
5. What is a local JVM test?
It is a test that runs on the development machine’s JVM without an Android device or emulator.
6. Where are local Android unit tests stored?
module/src/test/java/7. What is the difference between src/test and src/androidTest?
src/test contains local JVM tests. src/androidTest contains instrumented tests that run on a device or emulator.
8. What is JUnit?
JUnit is a testing framework that provides test execution, annotations, assertions, lifecycle methods and reporting.
9. What does @Test do?
It identifies a function as a test case that JUnit should execute.
10. What is the difference between @Before and @BeforeClass?
@Before runs before every test. @BeforeClass runs once before all tests in the class.
11. What is the difference between @After and @AfterClass?
@After runs after every test. @AfterClass runs once after the complete test class.
12. Why does Kotlin require @JvmStatic with JUnit 4 @BeforeClass?
JUnit 4 expects a static method. @JvmStatic exposes the companion-object function as a JVM static method.
13. What is an assertion?
An assertion verifies that the actual result matches the expected result.
14. How do you test an expected exception?
Use assertThrows and verify the exception type, message and important side effects.
15. What is parameterized testing?
It runs the same test logic against multiple sets of input and expected-output values.
16. When should parameterized tests be used?
Use them when setup and assertions remain the same while test data changes.
17. What is a pure function?
A pure function returns the same result for the same input and has no externally visible side effects.
18. Why are pure functions easy to test?
They do not depend on global state, external systems or Android framework components.
19. How should validation logic be tested?
Test valid, invalid, empty, null and boundary inputs, along with expected error messages.
20. What should be tested in a mapper?
Field mapping, null handling, default values, enum conversion, formatting and invalid input.
21. How do you unit-test a use case?
Instantiate the real use case with fake or mocked dependencies, execute it and verify its result and critical interactions.
22. What is a test double?
It is a controlled replacement for a real dependency in a test.
23. What is a dummy?
A placeholder dependency that is required but not used by the scenario.
24. What is a stub?
A test double that returns predefined responses.
25. What is a fake?
A simplified but working implementation, such as an in-memory repository.
26. What is a mock?
A configurable test double that records calls and supports interaction verification.
27. What is a spy?
A wrapper or partial mock that records calls while retaining some real behaviour.
28. Fake vs stub?
A stub normally returns fixed data. A fake usually contains simplified, stateful working behaviour.
29. Fake vs mock?
A fake is commonly used for state-based testing. A mock is commonly used for interaction-based testing.
30. When should a mock be used?
Use it when verifying an interaction is an important requirement, such as ensuring a payment is submitted exactly once.
31. What is stubbing?
Stubbing configures the response that a mock or stub should return for a call.
32. What is interaction verification?
It checks whether a dependency was called with expected arguments and frequency.
33. What is argument capturing?
It records an argument passed to a mocked dependency so its fields can be asserted.
34. What is over-mocking?
It is replacing or verifying too many implementation details, producing fragile and misleading tests.
35. Why can mocks provide false confidence?
A mock may be configured differently from the real component, so the unit test passes while real integration fails.
36. How does dependency injection improve testability?
It allows real dependencies to be replaced with fakes, stubs or mocks during testing.
37. Is Hilt required for dependency injection?
No. Constructor injection is dependency injection. Hilt automates wiring in larger Android applications.
38. Should private methods be tested directly?
Generally, no. Test public behaviour that uses the private method.
39. What should you do when a private method contains complex logic?
Consider extracting it into a separate class with a meaningful public API.
40. State verification vs interaction verification?
State verification checks the final output or state. Interaction verification checks calls made to collaborators.
41. Should unit tests call a real API?
No. Replace it with a stub, fake or mock. Test the real API boundary separately using integration tests.
42. Why should tests be independent?
Independent tests can run in any order, in parallel and without failures caused by shared state.
Quick Revision
@Test → Marks a test function
@Before → Runs before every test
@After → Runs after every test
@BeforeClass → Runs once before the class
@AfterClass → Runs once after the class
Dummy → Passed but not used
Stub → Returns prepared data
Fake → Simplified working implementation
Mock → Configured and interaction-verified
Spy → Real/partial behaviour plus call recording
src/test → Local JVM tests
src/androidTest → Device/emulator tests
Prefer:
Public behaviour
Constructor injection
Fakes for state
Mocks for important interactions
Clear Arrange–Act–Assert tests