Welcome to Part 2. In the previous section, we established our core philosophy: testing units in complete isolation. Now, we'll dive into the "how"—the specific architectural patterns and testing techniques we use to build and verify our isolated components.
Key Concepts Covered in Part 2:
- Architecting with interfaces for dependency injection and testability.
- Using sealed classes for robust error handling tests.
- The practical differences between Mocks, Fakes, and Spies.
- Strategies for testing asynchronous code and business logic layers.
How to Architect for Testable Code
Theory is grand, but how do we build a feature that can be tested in isolation? It starts with our architecture.
To illustrate, let's use a running example for the rest of this guide.
User Story: The Profile Screen As a user, when I open the profile screen, I want to see a loading indicator. Once the data is loaded, I want to see my name and profile picture. If there is a server error, I want to see a generic error message. If there is no internet connection, I want to see a specific 'No Internet' error with an option to retry.
This simple feature involves a UI, a component to hold the display logic (which could be a ViewModel
, Controller
, or Presenter
), and a Repository
for the data layer. We will design them to be perfectly isolated.
Defining Testable Contracts with Interfaces
Our single most important technique is "Program to an Interface, not an Implementation." This means our components depend on abstract contracts (interface
or abstract class
), not on concrete classes.
In our example, to handle state management, we use a simple State
sealed class and a MutableStatefulFlow
to handle emissions to the UI.
// State.kt
/**
* Wrapper class that is able to handle different state
*
* @param <T> data to wrap</T>
* */
sealed class State<T> {
var observed: AtomicBoolean = AtomicBoolean(false)
class Created<T> : State<T>()
class Loading<T> : State<T>()
data class Success<T>(val data: T) : State<T>()
data class Error<T>(val error: Throwable) : State<T>()
class Completed<T> : State<T>()
}
typealias StateFlow<T> = Flow<State<T>>
// MutableStatefulFlow.kt
open class MutableStatefulFlow<T> : Flow<State<T>> {
private val stateFlow = MutableStateFlow<State<T>>(State.Created())
val currentState: State<T>
get() = stateFlow.value
fun emitCreated() {
stateFlow.value = State.Created()
}
fun emitLoading() {
stateFlow.value = State.Loading()
}
fun emitError(throwable: Throwable) {
stateFlow.value = State.Error(throwable)
}
fun emitSuccess(data: T) {
stateFlow.value = State.Success(data)
}
fun emitCompleted() {
stateFlow.value = State.Completed()
}
override suspend fun collect(collector: FlowCollector<State<T>>) {
stateFlow.collect(collector)
}
}
Now, we define the specific contracts for our feature using these standard types.
// The data contract: What can a UserRepository do?
interface IUserRepository {
suspend fun fetchUserProfile(): Result<User>
}
// The UI logic contract.
abstract class IProfileViewModel : ViewModel() {
abstract val profileStateFlow: StateFlow<User>
abstract fun fetchProfile()
}
Note: We use
IProfileViewModel
here as an example. In other architectural patterns like MVC or MVP, this component might be namedIProfileController
orIProfilePresenter
. The name is less important than the principle: it is an interface that defines the contract for the component responsible for UI logic.
Building Ironclad Contracts
The IUserRepository
interface is a formal, written agreement between different parts of our software. It's a contract that says:
"Any class that claims to be an
IUserRepository
must provide a way tofetchUserProfile()
. I don't care how you do it—whether you get the data from a network, a database, or a text file—but you must fulfill this promise."
This allows our ProfileViewModel
to depend on the idea of a repository (IUserRepository
) rather than a specific, concrete implementation (UserRepositoryImpl
).
However, the Result.failure(error: Throwable)
part of this contract is a good start, but relying on a generic Throwable
is vague. It's like a legal agreement that doesn't define its terms—it leaves too much open to interpretation and creates ambiguity. To make our contract ironclad, we must explicitly describe every possible failure to cover all our bases, ensuring no surprises exist for the components that rely on it.
We achieve this by creating a sealed class
that extends Throwable
. This approach allows us to define a closed, exhaustive set of specific, well-defined error types that our data layer can produce, making our error handling fully deterministic.
We extend Throwable because it allows us to have a stacktrace if needed.
For example, when reporting to Firebase or DataDog, we can use the stacktrace to know who called our function.
/**
* Models the exhaustive set of well-defined errors that can originate from our repositories.
* This sealed hierarchy ensures that all possible error cases must be handled by the caller.
*/
sealed class RepositoryError(override val message: String) : Throwable(message) {
/** The request failed due to a network connectivity issue. */
data class NetworkError(val originalError: IOException) : RepositoryError("Network connection error")
/** The server responded with a non-2xx status code. */
data class ServerError(val code: Int) : RepositoryError("Server error with code: $code")
/** The data received from the server could not be parsed. */
object DeserializationError : RepositoryError("Failed to parse data")
/** An unknown or unexpected error occurred. */
data class UnknownError(val originalError: Throwable) : RepositoryError("An unknown or unexpected error occurred")
}
Now, the implicit contract of IUserRepository
is that if fetchUserProfile()
returns a Result.failure
, the associated Throwable
will be an instance of RepositoryError
. This makes our business logic and our tests far more robust and expressive. This pattern does more than make tests robust; it fundamentally makes our code easier to read, understand, and maintain. By defining an explicit set of possible failures, we reduce the number of unknowns, which is a massive benefit for any developer, especially someone new to the project.
Controlling Dependencies for Testability
To reliably test asynchronous code and other external dependencies, we must be able to control them from outside the class being tested. Hardcoding dependencies like Dispatchers.IO
or concrete service classes makes this very difficult. The solution is to use Dependency Injection, where we define interfaces for our dependencies and provide them through a class's constructor. For example, to manage coroutine execution, we can define a simple IAppDispatchers
interface.
interface IAppDispatchers {
val io: CoroutineDispatcher
val main: CoroutineDispatcher
}
class AppDispatchers : IAppDispatchers {
override val io: CoroutineDispatcher = Dispatchers.IO
override val main: CoroutineDispatcher = Dispatchers.Main
}
In our production code, we inject the standard AppDispatchers
. For our tests, however, we can create a TestAppDispatchers
implementation that gives us full control over coroutine execution.
class TestAppDispatchers(private val testDispatcher: TestDispatcher) : IAppDispatchers {
override val io: CoroutineDispatcher = testDispatcher
override val main: CoroutineDispatcher = testDispatcher
}
From Contracts to Concrete Code
Fulfilling the Contracts with Implementations
The following code shows how these interfaces are implemented within common architectural patterns like MVC, MVP, or MVVM.
// The real implementation that talks to a network service
class UserRepositoryImpl(
private val apiService: ProfileApiService,
private val dispatchers: IAppDispatchers
) : IUserRepository {
override suspend fun fetchUserProfile(): Result<User> =
withContext(dispatchers.io) {
// ... complex logic to call a remote service, handle errors, parse JSON,
// and map them to our specific RepositoryError types.
}
}
// The real implementation that talks to a network service
class UserRepositoryImpl(
private val apiService: ProfileApiService,
private val dispatchers: IAppDispatchers
) : IUserRepository {
override suspend fun fetchUserProfile(): Result<User> =
withContext(dispatchers.io) {
// ... complex logic to call Retrofit, handle errors, parse JSON,
// and map them to our specific RepositoryError types.
}
}
// The real implementation with the business logic
class ProfileViewModelImpl(
private val userRepository: IUserRepository,
private val dispatchers: IAppDispatchers
) : IProfileViewModel() {
override val profileStateFlow = MutableStatefulFlow<User>() // Downcast to `StateFlow` automatically
// Public function to be called from the UI on initial load or retry
override fun fetchProfile() {
// By default, `viewModelScope.launch` uses `dispatchers.main`.
// It's important to make sure that either your ViewModel or your Repository handles context switching!
viewModelScope.launch {
profileStateFlow.emitLoading()
// The repository's suspend function handles its own context switching (to IO).
// Once it completes, the coroutine resumes on the original dispatcher (main) to handle the result.
userRepository.fetchUserProfile().fold(
onSuccess = { user -> profileStateFlow.emitSuccess(user) },
onFailure = { error ->
// The error is a RepositoryError, which our UI can use to show specific messages.
profileStateFlow.emitError(error)
}
)
}
}
}
Notice ProfileViewModelImpl
depends on IUserRepository
, not UserRepositoryImpl
. This is the key that unlocks testability.
A Note on Private Functions and Abstractions
You might notice that ProfileViewModelImpl
is simple enough not to need any internal helper functions. As logic grows, a common instinct is to create private
functions to break up the work. However, when building on abstractions, we should favour internal
or public
functions.
When a consumer holds a reference to IProfileViewModel
, they can only see the methods defined in that interface. Any other public
or internal
methods in the implementation are effectively hidden from that consumer, providing the same encapsulation as private
would. The benefit of avoiding private
is testability. It allows a test module to access helper functions without forcing us to use spies or other complex testing patterns.
Connecting the Pieces with a DI Framework
Finally, how do we connect our concrete implementations in the real app? With a Dependency Injection framework. Our team uses Koin, but this can easily be translated to any other DI solution like Dagger, Hilt, or Spring.
// In your DI module
val profileModule = module {
// Assume IAppDispatchers is provided in another module (e.g., a core app module)
single<IUserRepository> { UserRepositoryImpl(get(), get()) }
factory<IProfileViewModel> { ProfileViewModelImpl(get(), get()) }
}
Handling State in the UI
The power of using a sealed class
for state becomes apparent in the UI layer, where it can deterministically change its presentation.
// Hypothetical UI rendering logic
fun renderProfileScreen(viewModel: IProfileViewModel) {
// Observe the state flow from the view model
viewModel.profileStateFlow.collect { state ->
when (state) {
is State.Loading -> {
showLoadingSpinner()
}
is State.Success -> {
showUserProfile(state.data)
}
is State.Error -> {
when (val error = state.error) {
is RepositoryError.NetworkError -> {
showNoInternetErrorView(onRetry = { viewModel.fetchProfile() })
}
is RepositoryError.ServerError -> {
showServerErrorView(errorCode = error.code)
}
else -> {
showGenericErrorView()
}
}
}
is State.Created -> { /* Render initial empty state */ }
}
}
}
This creates a much better user (and developer!) experience than showing a single, generic error message for every possible failure.
Writing the Tests: Our Toolkit in Practice
Now that we have a testable architecture, let's finally write the tests.
The Anatomy of a Test: Given, When, Then
We follow the Given-When-Then pattern to keep our tests organized, readable, and consistent.
- Given: Set up the initial state and preconditions. This is where you instantiate your class under test and prepare any mock dependencies.
- When: Execute the action or method you want to test.
- Then: Assert the expected outcome. Verify the final state or check that interactions with mocks happened correctly.
Test Doubles: Mocks, Fakes, and Spies
To test a unit in isolation, we must replace its real dependencies with "test doubles." The three main types we use are Mocks, Fakes, and Spies, each with a specific purpose. When choosing a test double, it's helpful to consider what you're trying to assert. Are you testing an interaction or a state change?
1. Mocks: For Verifying Interactions
A mock is a "dummy" object with no logic of its own; it only does what we tell it to for a specific test. Mocks are primarily used for stubbing (defining a specific return value for a function call) and verification (checking if a method on the dependency was called). They are ideal when you want to answer the question, "Did my code ask its dependency to do the right thing?"
// We use `mockk`, but the concept applies to Mockito, Jest, etc.
val mockRepository: IUserRepository = `mockk`()
// Stubbing: "When fetchUserProfile() is called, return this fake user"
coEvery { mockRepository.fetchUserProfile() } returns Result.success(fakeUser)
// Verification: "After my code runs, was fetchUserProfile() called exactly once?"
coVerify(exactly = 1) { mockRepository.fetchUserProfile() }
A key consideration with mocks is strictness. A strict mock (the default in mockk
) will fail a test if any method is called that wasn't explicitly stubbed, which is safe. A relaxed mock (mockk(relaxed = true)
) will return default empty values instead (e.g., Unit
, 0
, null
, false
), which can be convenient but may hide unexpected behaviour.
2. Fakes: For Asserting State Changes
A Fake is a lightweight, working implementation of a dependency that emulates its functionality with a simple alternative, like an in-memory List
instead of a real database. Fakes are best when you need to assert the consequences of an action. They help answer the question, "After my code ran, did the state of a dependency change in the way I expected?" This is particularly useful for dependencies with complex internal logic or when you want to create reusable assertion logic. For example, a fake analytics tracker allows us to verify that a specific event was tracked with the correct data, which is much cleaner than just verifying that a trackEvent
method was called.
First, we define the contract for our tracker:
interface AnalyticsTracker {
fun trackEvent(eventName: String, attributes: Map<String, String>)
}
Next, we create the Fake
for our tests. Notice that it contains its own verification methods, which makes our tests more expressive and readable.
class FakeAnalyticsTracker : AnalyticsTracker {
private val trackedEvents = mutableListOf<Pair<String, Map<String, String>>>()
override fun trackEvent(eventName: String, attributes: Map<String, String>) {
trackedEvents.add(eventName to attributes)
}
// Reusable assertion logic lives inside the Fake
fun verifyEventTracked(eventName: String, expectedAttributes: Map<String, String>) {
val event = trackedEvents.find { it.first == eventName }
assertNotNull(event, "Event '$eventName' was not tracked.")
assertEquals(expectedAttributes, event.second, "Attributes for event '$eventName' did not match.")
}
fun verifyNoEventsTracked() {
assertTrue(trackedEvents.isEmpty(), "Expected no events to be tracked, but found ${trackedEvents.size}.")
}
}
Finally, we can use this Fake
to write cleaner assertions in a test.
@Test
fun `fetchProfile should track success event when repository returns success`() {
// Given
val fakeAnalytics = FakeAnalyticsTracker()
val mockRepo: IUserRepository = `mockk`() // Your other dependencies can still be mocks
coEvery { mockRepo.fetchUserProfile() } returns Result.success(User(id = "user-123", ...))
val viewModel = ProfileViewModelImpl(mockRepo, fakeAnalytics)
// When
viewModel.fetchProfile()
// Then
// The test is now much more readable by using the Fake's verification method
fakeAnalytics.verifyEventTracked(
eventName = "profile_load_success",
expectedAttributes = mapOf("user_id" to "user-123")
)
}
3. Spies: For Verifying Internal Logic (Use with Caution)
A Spy is a powerful but dangerous test double. It offers a hybrid approach by wrapping a real object, allowing its actual methods to be executed by default while still giving us the power to override or verify specific ones. This power comes with a significant trade-off: it breaks our Principle of Isolation and should be used with extreme caution.
The most legitimate use case for a spy is to verify that one public method on an object calls another method on the same object. This can often be a sign that a class has too many responsibilities and should be refactored, but it can be a valid testing scenario.
// Class to be tested
class DataProcessor(private val logger: Logger) {
fun process(data: String): String {
val formattedData = formatData(data) // Internal call we want to verify
logger.log(formattedData)
return formattedData
}
internal fun formatData(data: String): String {
return "Processed: $data"
}
}
// Test with a Spy
@Test
fun `process should call formatData`() {
// Given
val mockLogger: Logger = `mockk`(relaxed = true)
val processorSpy = spyk(DataProcessor(mockLogger)) // Create a spy on the REAL object
// When
processorSpy.process("MyData")
// Then
// Verify that the internal 'formatData' method was called
verify { processorSpy.formatData("MyData") }
}
However, it's critical to understand when not to use a spy. A common anti-pattern is using a spy to fake a dependency's behaviour. This turns a unit test into a slow and flaky integration test because it relies on the real implementation of other components.
// Anti-Pattern: Do NOT do this
@Test
fun `fetchProfile should call repository - BAD EXAMPLE`() {
// Given
// Spying on the ViewModel to fake one of its dependencies
val spiedViewModel = spyk(ProfileViewModelImpl(UserRepositoryImpl(FakeApiService())))
// Faking what the repository does by stubbing the spy
coEvery { spiedViewModel.userRepository.fetchUserProfile() } returns Result.success(User("Test", ""))
// When...
// ...
}
In this bad example, the test is no longer isolated because it instantiates a real UserRepositoryImpl
. The correct approach is to mock the IUserRepository
dependency directly. As a rule, always prefer mocking external dependencies over spying on the class under test.
Unit Testing in Layers: Repositories and ViewModels
Recalling our Lego analogy from Part 1, we verify each "brick" in isolation. Once we're confident that each “layer” works correctly as a standalone unit, we can test how they're assembled.
We'll start with the Repository
—our data-providing brick—before moving on to the ViewModel
, which orchestrates the logic.
Testing the Repository Layer
Following our "Contract of Trust," it's time to test our UserRepositoryImpl
. Here, we mock its dependency (the ProfileApiService
) and verify that our repository correctly maps the network response and handles different outcomes. We assume the remote service will give us a Response
or throw an IOException
, and we test that our code handles those outcomes correctly.
class UserRepositoryImplTest {
private val mockApiService: ProfileApiService = `mockk`()
// The test dispatchers would be injected here as well
private val testDispatchers = TestAppDispatchers(UnconfinedTestDispatcher())
@Test
fun `fetchUserProfile should return mapped User on successful network response`() = runTest {
// Given: A successful response with a data transfer object (DTO)
val userDto = UserDto(fullName = "John Doe", avatarUrl = "[http://example.com/avatar.png](http://example.com/avatar.png)")
val successfulResponse = Response.success(userDto)
val repository = UserRepositoryImpl(mockApiService, testDispatchers)
coEvery { mockApiService.fetchUserProfile() } returns successfulResponse
// When
val result = repository.fetchUserProfile()
// Then: Assert the result is success and the DTO was mapped to our domain model
assertTrue(result.isSuccess)
val user = result.getOrNull()
assertNotNull(user)
assertEquals("John Doe", user.name)
assertEquals("[http://example.com/avatar.png](http://example.com/avatar.png)", user.avatar)
// Verify we called the dependency
coVerify { mockApiService.fetchUserProfile() }
}
@Test
fun `fetchUserProfile should return ServerError on non-2xx network response`() = runTest {
// Given: An error response from the server
val errorBody = "Not Found".toResponseBody("text/plain".toMediaTypeOrNull())
val errorResponse = Response.error<UserDto>(404, errorBody)
val repository = UserRepositoryImpl(mockApiService, testDispatchers)
coEvery { mockApiService.fetchUserProfile() } returns errorResponse
// When
val result = repository.fetchUserProfile()
// Then: Assert the result is a failure and is our specific ServerError
assertTrue(result.isFailure)
val exception = result.exceptionOrNull()
assertTrue(exception is RepositoryError.ServerError)
assertEquals(404, exception.code)
}
@Test
fun `fetchUserProfile should return NetworkError when api service throws IOException`() = runTest {
// Given: The network call itself fails
val networkException = IOException("No internet")
val repository = UserRepositoryImpl(mockApiService, testDispatchers)
coEvery { mockApiService.fetchUserProfile() } throws networkException
// When
val result = repository.fetchUserProfile()
// Then: Assert the result is a failure and is our specific NetworkError
assertTrue(result.isFailure)
val exception = result.exceptionOrNull()
assertTrue(exception is RepositoryError.NetworkError)
assertEquals(networkException, exception.originalError)
}
}
Testing the ViewModel Layer
Now that we have confidently tested our Repository
brick, we can test the ProfileViewModelImpl
. The goal here is not to re-test the Repository
's logic but to verify that the ViewModel
handles all of its internal logic, and every known output the Repository
might throw at us. The more scenarios you have tests for, the higher your “coverage” is.
class ProfileViewModelImplTest {
private val mockUserRepository: IUserRepository = `mockk`()
private lateinit var testDispatchers: TestAppDispatchers
private lateinit var viewModel: ProfileViewModelImpl
// Helper to set up the ViewModel before each test
private fun setupViewModel(testDispatcher: TestDispatcher) {
testDispatchers = TestAppDispatchers(testDispatcher)
viewModel = ProfileViewModelImpl(mockUserRepository, testDispatchers)
}
@Test
fun `fetchProfile should emit Success when repository returns success`() = runTest {
// Given
setupViewModel(this.testScheduler) // Use the scheduler from runTest
val fakeUser = User("Jane Doe", "jane.jpg")
coEvery { mockUserRepository.fetchUserProfile() } returns Result.success(fakeUser)
// When
viewModel.fetchProfile()
// Then
viewModel.profileStateFlow.test {
assertTrue(awaitItem() is State.Created)
assertTrue(awaitItem() is State.Loading)
val successState = awaitItem()
assertTrue(successState is State.Success)
assertEquals(fakeUser, successState.data)
cancelAndIgnoreRemainingEvents()
}
coVerify(exactly = 1) { mockUserRepository.fetchUserProfile() }
}
@Test
fun `fetchProfile should emit Error when repository returns a ServerError`() = runTest {
// Given
setupViewModel(this.testScheduler)
val specificError = RepositoryError.ServerError(404)
coEvery { mockUserRepository.fetchUserProfile() } returns Result.failure(specificError)
// When
viewModel.fetchProfile()
// Then
viewModel.profileStateFlow.test {
assertTrue(awaitItem() is State.Created)
assertTrue(awaitItem() is State.Loading)
val errorState = awaitItem()
assertTrue(errorState is State.Error)
assertEquals(specificError, errorState.error)
cancelAndIgnoreRemainingEvents()
}
coVerify(exactly = 1) { mockUserRepository.fetchUserProfile() }
}
@Test
fun `fetchProfile should emit Error when repository returns a NetworkError`() = runTest {
// Given
setupViewModel(this.testScheduler)
val specificError = RepositoryError.NetworkError(IOException("No connection"))
coEvery { mockUserRepository.fetchUserProfile() } returns Result.failure(specificError)
// When
viewModel.fetchProfile()
// Then
viewModel.profileStateFlow.test {
assertTrue(awaitItem() is State.Created)
assertTrue(awaitItem() is State.Loading)
val errorState = awaitItem()
assertTrue(errorState is State.Error)
assertEquals(specificError, errorState.error)
cancelAndIgnoreRemainingEvents()
}
coVerify(exactly = 1) { mockUserRepository.fetchUserProfile() }
}
}
By using our RepositoryError
sealed class, our failure test no longer just checks "did it fail?"—it's checking "did it fail for the exact reason we expected?" This makes our tests incredibly precise.
Handling Asynchronicity in Tests
Effective asynchronous programming and concurrency testing is crucial for modern applications. While our examples use Kotlin Coroutines, the principles apply to Promises, Futures, or async/await as well. The key is to use libraries that give you control over the execution of asynchronous tasks.
-
runTest
: This is the modern, standard tool for testing coroutines. It creates a specialTestScope
that runs your test code in a virtual time environment, making your async tests both extremely fast and deterministic. -
Turbine: This is a small, invaluable library for testing
Flows
. As shown in the example above,flow.test { ... }
provides a simple, structured way to assert each emission from aFlow
in the correct order.
Case Study: Testing Complex Methods
Real-world methods often have more than one responsibility. For example, a method might fetch data, map it for the UI, and also map it for an analytics event.
Scenario: The
ProfileViewModel
needs to track an analytics event when the profile is loaded successfully.
Approach 1: Testing the Monolithic Method
First, look at the less ideal, 'monolithic' approach where the ViewModel
does everything.
The Code:
class ProfileViewModel(
private val userRepository: IUserRepository,
private val analyticsTracker: AnalyticsTracker,
private val dispatchers: IAppDispatchers
) {
fun fetchProfileAndTrack() {
//...
userRepository.fetchUserProfile().onSuccess { user ->
// Responsibility 1: Update UI State
_uiState.emitSuccess(user)
// Responsibility 2: Map data for analytics
val attributes = mapOf(
"user_id" to user.id,
"user_name_length" to user.name.length.toString()
)
// Responsibility 3: Track the event
analyticsTracker.trackEvent("profile_loaded", attributes)
}
}
}
The Test: This test is now responsible for verifying the UI state and the exact structure of the analytics event.
@Test
fun `fetchProfileAndTrack should emit Success and track event`() {
// Given
val user = User(id = "user-123", name = "Jane Doe")
coEvery { mockUserRepository.fetchUserProfile() } returns Result.success(user)
// When
viewModel.fetchProfileAndTrack()
// Then
// 1. Verify the analytics call with the exact, hardcoded map
val expectedAttributes = mapOf(
"user_id" to "user-123",
"user_name_length" to "8"
)
verify { mockAnalyticsTracker.trackEvent("profile_loaded", expectedAttributes) }
// 2. Verify the UI state
// ...
}
This test works, but it's brittle. This ViewModel test will break if the analytics requirements change, even though the UI state logic is unchanged, thus giving the test too many reasons to fail.
Approach 2: Refactoring for Testability (The Recommended Way)
The better approach is isolating the mapping logic into a small, testable unit.
The Refactored Code: Create a dedicated, single-responsibility mapper.
// A new, easily testable unit
class UserAnalyticsMapper {
fun map(user: User): Map<String, String> {
return mapOf(
"user_id" to user.id,
"user_name_length" to user.name.length.toString()
)
}
}
// The ViewModel now delegates the mapping
class ProfileViewModel(
private val userRepository: IUserRepository,
private val analyticsTracker: AnalyticsTracker,
private val mapper: UserAnalyticsMapper, // New dependency
private val dispatchers: IAppDispatchers
) {
fun fetchProfileAndTrack() {
//...
userRepository.fetchUserProfile().onSuccess { user ->
_uiState.emitSuccess(user)
val attributes = mapper.map(user) // Just call the mapper
analyticsTracker.trackEvent("profile_loaded", attributes)
}
}
}
The New, Simpler Tests:
First, we write a highly-focused test for our new mapper. It has no mocks and is extremely simple.
// Test for the mapper
class UserAnalyticsMapperTest {
@Test
fun `map should correctly transform user to analytics attributes`() {
// Given
val mapper = UserAnalyticsMapper()
val user = User(id = "user-123", name = "Jane Doe")
// When
val attributes = mapper.map(user)
// Then
assertEquals(2, attributes.size)
assertEquals("user-123", attributes["user_id"])
assertEquals("8", attributes["user_name_length"])
}
}
Now, the ViewModel
test can be simpler. It no longer needs to know how the mapping is done; it only needs to verify that the components are called in the correct order.
// Simplified ViewModel test
// Assume mockMapper is created via 'val mockMapper: UserAnalyticsMapper = `mockk`(relaxed = true)'
@Test
fun `fetchProfileAndTrack should call mapper and track event on success`() {
// Given
val user = User(id = "user-123", name = "Jane Doe")
coEvery { mockUserRepository.fetchUserProfile() } returns Result.success(user)
// Note: Because mockMapper can be a relaxed mock, we don't need to stub
// its 'map' function. It will return a default empty map, which is fine
// for this test since we use any() in the trackEvent verification.
// When
viewModel.fetchProfileAndTrack()
// Then
// We only verify that our components were called correctly.
// The test for the exact mapping logic lives in UserAnalyticsMapperTest.
verify { mockMapper.map(user) }
verify { mockAnalyticsTracker.trackEvent("profile_loaded", any()) }
}
By refactoring, we created two simpler, more focused tests that are more resilient to change and easier to understand. This is a core tenet of writing maintainable, long-lasting tests.
We've now seen how to architect our code for isolation and use test doubles to verify its behaviour. With these techniques, we can build robust components. The final part of this handbook will cover our specific team standards for ensuring consistency, readability, and long-term maintainability across our entire test suite.
Top comments (0)