When building Android applications, writing unit tests is crucial to ensure your code behaves as expected. As your app grows in complexity, testing individual components becomes more challenging, especially when there are dependencies between different parts of your code. This is where mocks and spies come into play, helping you isolate and test units effectively without interference from dependencies.
In this article, we’ll explore the roles of mocks and spies in Android unit testing using Kotlin. We’ll also go over examples to help you apply these concepts in your Android projects.
What is Unit testing?
Unit testing is a type of software testing where individual units or components of a software application are tested in isolation from the rest of the system. A unit refers to the smallest testable part of the application, such as a function, method, or class. The primary goal of unit testing is to ensure that each unit of the software performs as expected, independent of other parts of the system.
Key Characteristics of Unit Tests:
- Isolated Testing: Unit tests focus on testing a single piece of functionality in isolation. This means that dependencies (like databases, APIs, or other classes) are often mocked or stubbed out to prevent side effects or dependencies from influencing the test.
- Fast and Lightweight: Since unit tests don’t rely on external systems, they are typically fast and lightweight. They don’t involve setting up databases, network connections, or other system components.
- Automated: Unit tests are usually automated and executed frequently (e.g., as part of a continuous integration (CI) pipeline). This allows for quick feedback during development.
- Repeatable: Unit tests should be deterministic, meaning they produce the same result every time they run, assuming the code under test hasn’t changed.
Benefits of Unit Testing:
- Bug Detection: Unit tests help detect issues early in the development cycle, catching bugs before they make their way into production code.
- Improved Code Quality: Writing unit tests encourages developers to write more modular and cleaner code. Since unit tests work best on small, self-contained functions or classes, this often leads to better-designed code.
- Refactoring with Confidence: Unit tests serve as a safety net when refactoring code. Since unit tests verify the functionality of each component, developers can refactor code confidently, knowing that existing functionality is still preserved.
- Documentation: Unit tests can act as living documentation. Well-written tests describe the expected behavior of functions or classes, making it easier for new developers to understand how a particular piece of code is supposed to work.
- Faster Debugging: Unit tests make debugging easier by quickly narrowing down which specific part of the code is malfunctioning.
What Are Mocks and Spies?
Before diving into the code, let’s first understand what mocks and spies are.
- Mock: A mock is a fake object that mimics the behavior of a real object. When writing unit tests, mocks allow us to replace dependencies with controlled behavior to test the code in isolation.
// Start mock
List mockedList = mock(List.class);
// Using mock object
mockedList.add("one");
mockedList.clear();
// Verificatiion
verify(mockedList).add("one");
verify(mockedList).clear();
- Spy: A spy wraps around an actual object. It lets you keep the object’s real behavior but allows you to “spy” on method calls, check interactions, or override specific behaviors.
You can create spies of real objects. When you use the spy then the real methods are called (unless a method was stubbed). Real spies should be used carefully and occasionally, for example when dealing with legacy code.
List list = new LinkedList();
List spy = spy(list);
// Optionally, you can stub out some methods:
when(spy.size()).thenReturn(100);
// Using the spy calls real methods
spy.add("one");
spy.add("two");
// Prints "one" - the first element of a list
System.out.println(spy.get(0));
// Size() method was stubbed - 100 is printed
System.out.println(spy.size());
// Optionally, you can verify
verify(spy).add("one");
verify(spy).add("two");
Why Use Mocks and Spies?
Mocks and spies play an essential role in isolating the unit of code being tested, especially when that unit depends on external systems (like databases, APIs, or other services). By replacing real dependencies with mocks or spies, we can:
- Test the logic of a class without relying on real dependencies.
- Avoid external side effects like network calls or database reads/writes.
- Control the behavior of external systems during testing.
This leads to faster and more reliable tests since there’s no need to set up or access external systems.
When to Use Mocks vs Spies?
- Mocks: Use when you want to completely replace a dependency and don’t care about its real implementation. Mocks are typically used for dependencies such as APIs, databases, or services that interact with external systems.
- Spies: Use when you want to keep the real behavior of a class but monitor its methods or partially modify some of its behavior. Spies are useful when you want to test a real class but need to track its interactions.
Differences Between Mocks and Spies
Mock | Spy |
---|---|
Replaces a real object entirely | Uses the real object but monitors it |
Doesn't call real methods by default | Calls real methods unless specified. |
Primarily used for behavior definition | Primarily used for behavior observation. |
Practical Example
Imaging we have a GetAppConfigUseCase
class that fetches data from a GetAppConfigRepository
, we don’t want to hit a real repository or database, so we’ll mock GetAppConfigRepository
to control its behavior.
GetAppConfigRepository
// Domain | |
class AppConfig(val name: String) | |
// Data | |
internal class AppConfigResponse( | |
val id: String, | |
val name: String | |
) | |
internal class AppConfigRepositoryImpl : AppConfigRepository { | |
override suspend fun getConfig( | |
onSuccess: (AppConfig) -> Unit, onFailure: (Throwable?) -> Unit | |
) { | |
withContext(Dispatchers.IO) { | |
try { | |
val id = UUID.randomUUID().toString() | |
val response = AppConfigResponse(id, id.substring(0, 10)) | |
onSuccess(AppConfig(name = response.name)) | |
} catch (e: Exception) { | |
onFailure(e) | |
} | |
} | |
} | |
} |
GetAppConfigUseCase
class GetAppConfigUseCase(private val repository: AppConfigRepository) { | |
suspend operator fun invoke() { | |
repository.getConfig( | |
onSuccess = ::save, | |
onFailure = ::logError | |
) | |
} | |
fun save(config: AppConfig) { | |
} | |
fun logError(throwable: Throwable?) { | |
} | |
} |
GetAppConfigUseCaseUnitTest
Now let’s write the unit tests using Mockito to mock the GetAppConfigUseCase
class, and to verify that the onSuccess
and onFailure
methods are called correctly. Create a test class in your src/test/java/
folder and write the unit test using mock like below:
@RunWith(MockitoJUnitRunner::class) | |
class GetAppConfigUseCaseTest { | |
private lateinit var testObject: GetAppConfigUseCase | |
@Mock | |
private lateinit var repository: AppConfigRepository | |
@Before | |
fun setUp() { | |
testObject = GetAppConfigUseCase(repository) | |
} | |
@Test | |
fun `GIVEN AppConfig WHEN call invoke THEN see correct AppConfig`() = runTest { | |
// Given | |
// Use spy to verify internal method calls | |
testObject = spy(testObject) | |
val expectedAppConfig = AppConfig("name") | |
// Capture the onSuccess and onFailure callbacks | |
val onSuccess = argumentCaptor<(AppConfig) -> Unit>() | |
val onFailure = argumentCaptor<(Throwable?) -> Unit>() | |
// When | |
testObject.invoke() | |
// Verify that onSuccess, onFailure were called and capture the callbacks | |
verify(repository).getConfig(onSuccess.capture(), onFailure.capture()) | |
// Simulate onSuccess callback being triggered | |
onSuccess.firstValue.invoke(expectedAppConfig) | |
// Then | |
// Verify onSuccess is called | |
verify(testObject).save(expectedAppConfig) | |
} | |
} |
Job Offers
- Mock: create a mock for
GetAppConfigRepository
using @Mock - Argument Capture: We use
argumentCaptor
to capture the callbacks passed togetConfig
so we can invoke them manually in the test. - Spies:
spy(testObject)
allows us to verify the internal method calls (onSuccess
andonFailure
) while preserving the logic of theUseCase
class.
Test Cases:
- Success Case: The test checks if
save
is called whenonSuccess
is triggered. - Failure Case: The test checks if
logError
is called with the correct parameters whenonFailure
is triggered.
Required Dependencies
// Unit testing libraries
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:3.11.2'
testImplementation 'org.mockito:mockito-inline:3.11.2'
testImplementation "org.jetbrains.kotlin:kotlin-test:1.5.21"
testImplementation "org.jetbrains.kotlin:kotlin-test-junit:1.5.21"
Conclusion
Unit testing is a crucial part of Android development, ensuring that individual components work as expected without relying on external systems. By using mocks and spies, you can isolate your code, control dependencies, and write fast, reliable tests.
- Mocks replace real objects with fakes to control their behavior.
- Spies wrap around real objects to monitor or modify their behavior during tests.
With this knowledge, you can now confidently implement unit tests in your Android projects using mocks and spies in Kotlin!
Besides mock and spy, there are several other techniques, tools, and practices that support and enhance unit testing in Android development with Kotlin. These help in writing more efficient, maintainable, and reliable tests. Here’s an overview of additional concepts and tools that support unit testing:
1. Stubs
- What is it? A stub is a simplified implementation of a class or method that returns hard-coded data. Unlike mocks, stubs do not verify interactions or behavior; they are just placeholders that provide predefined responses.
- When to use: Use stubs when you need a predictable and simple result without focusing on how it’s called.
class FakeUserRepository : UserRepository {
override fun getUserById(userId: String): User {
// Returning a hard-coded user for testing
return User("1", "Jane Doe")
}
}
Stubs are useful when you don’t need the flexibility of mocks, but just want simple data for your test cases.
2. Fake Objects
- What is it? A fake is a simple, usually in-memory implementation of an interface or class, which mimics the behavior of the real object. Fakes are used when creating the real object is too expensive (e.g., database connections, network calls).
- When to use: Use fakes to simulate interactions with external systems, like databases or APIs, but with simplified behavior.
class InMemoryUserRepository : UserRepository {
private val users = mutableListOf<User>()
override fun getUserById(userId: String): User {
return users.find { it.id == userId } ?: throw NoSuchElementException("User not found")
}
fun addUser(user: User) {
users.add(user)
}
}
Here, InMemoryUserRepository
is a fake that mimics the real repository but uses an in-memory list to simulate database functionality.
3. Test Fixtures
- What is it? A fixture is a known set of data or objects that is used to ensure tests run with consistent inputs. Test fixtures make sure that your test runs in a controlled environment by setting up the necessary preconditions.
- When to use: Use fixtures when you need a specific state or set of data before running your tests, ensuring consistency across different tests.
class UserServiceTest {
private lateinit var userService: UserService
private lateinit var mockRepository: UserRepository
@Before
fun setup() {
mockRepository = mock(UserRepository::class.java)
`when`(mockRepository.getUserById("1")).thenReturn(User("1", "John Doe"))
userService = UserService(mockRepository)
}
@Test
fun `should return user name correctly`() {
val userName = userService.getUserName("1")
assertEquals("John Doe", userName)
}
}
In this case, the @Before
annotated method setup()
acts as a fixture by initializing and configuring the necessary objects before each test.
4. Assertions
- What is it? Assertions are used to check the correctness of the test’s results. They are critical in verifying that the expected outcomes match the actual results. Frameworks like JUnit provide built-in assertions, while libraries like Hamcrest and Truth offer more expressive and readable assertions.
- When to use: Use assertions in every test to verify that the actual output matches the expected behavior.
Example using JUnit Assertions:
@Test
fun `should return user name correctly`() {
val userName = userService.getUserName("1")
assertEquals("John Doe", userName)
}
Example using Hamcrest:
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.MatcherAssert.assertThat
@Test
fun `should return user name correctly`() {
val userName = userService.getUserName("1")
assertThat(userName, `is`("John Doe"))
}
Hamcrest’s assertThat()
is more readable and is especially useful for complex assertions.
5. Parameterized Tests
- What is it? A parameterized test allows you to run the same test with different sets of data. This is useful when you want to test multiple cases without duplicating test logic.
- When to use: Use parameterized tests when you need to verify a method or class with multiple inputs and outputs.
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.junit.Assert.assertEquals
@RunWith(Parameterized::class)
class CalculatorTest(private val input1: Int, private val input2: Int, private val expected: Int) {
companion object {
@JvmStatic
@Parameterized.Parameters
fun data() = listOf(
arrayOf(1, 2, 3),
arrayOf(2, 3, 5),
arrayOf(5, 5, 10)
)
}
private val calculator = Calculator()
@Test
fun `test addition`() {
assertEquals(expected, calculator.add(input1, input2))
}
}
This allows you to test the Calculator.add()
method with various inputs without writing multiple test methods.
6. Test Doubles (Dummies)
- What is it? A test double is a general term for any object used in place of a real one in unit tests. Mocks, stubs, and fakes are all forms of test doubles. Dummies are a type of test double that is passed around but never actually used. They are only there to satisfy parameter requirements.
- When to use: Use dummies when you need to provide an argument but don’t care about its behavior during the test.
class OrderServiceTest {
@Test
fun `should process order`() {
val dummyCustomer = Customer(id = "123") // Not used, just to satisfy the signature
val orderService = OrderService(dummyCustomer)
orderService.processOrder()
}
}
7. Timeouts
- What is it? A timeout specifies the maximum duration that a test is allowed to run before being forcibly terminated. This ensures that your tests do not hang indefinitely.
- When to use: Use timeouts to avoid long-running tests, especially when dealing with asynchronous code or network operations.
@Test(timeout = 1000) // Test will fail if it runs longer than 1 second
fun `test method with timeout`() {
Thread.sleep(500) // This will pass
}
8. Code Coverage Tools
- What is it? Code coverage tools help you measure how much of your code is exercised by your unit tests. Tools like JaCoCo can show you what percentage of your code is covered by tests and highlight areas that are not being tested.
- When to use: Use code coverage tools to identify untested parts of your codebase and improve your test suite.
// Add the following to your build.gradle to enable JaCoCo:
apply plugin: 'jacoco'
jacoco {
toolVersion = "0.8.7"
}
tasks.jacocoTestReport {
reports {
xml.enabled = true
html.enabled = true
}
}
// Run the following to generate a coverage report:
./gradlew test jacocoTestReport
9. Test Runners
- What is it? A test runner is a component that orchestrates the execution of your test cases. In Android, the default test runner is AndroidJUnitRunner, but you can also use custom runners like Robolectric for simulating Android components in unit tests.
- When to use: Use a test runner to execute and manage your tests, particularly when you have special requirements like testing UI or Android components.
10. Robolectric
- What is it? Robolectric is a framework that allows you to run Android tests on the JVM without the need for an emulator or real device. It provides shadow implementations of Android APIs.
- When to use: Use Robolectric to test Android components (like
Activity
,Fragment
,View
) in isolation without requiring a device or emulator.
@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
@Test
fun `should display correct text`() {
val activity = Robolectric.buildActivity(MainActivity::class.java).create().get()
val textView = activity.findViewById<TextView>(R.id.text_view)
assertEquals("Hello World!", textView.text.toString())
}
}
References:
- Mockito Documentation
- https://github.com/mockito/mockito
- https://martinfowler.com/articles/mocksArentStubs.html
- Unit Testing in Android
https://github.com/nphausg?source=post_page—–66e23b0e330b——————————–
This article is previously published on proandroiddev.com