DEV Community

Cover image for Part 2: From Theory to Practice (Show me the code!)
Gil Goldzweig Goldbaum
Gil Goldzweig Goldbaum

Posted on

Part 2: From Theory to Practice (Show me the code!)

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>>
Enter fullscreen mode Exit fullscreen mode
// 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Note: We use IProfileViewModel here as an example. In other architectural patterns like MVC or MVP, this component might be named IProfileController or IProfilePresenter. 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 to fetchUserProfile(). 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")
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
                }
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()) }
}
Enter fullscreen mode Exit fullscreen mode

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 */ }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
Enter fullscreen mode Exit fullscreen mode

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>)
}
Enter fullscreen mode Exit fullscreen mode

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}.")
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
    )
}
Enter fullscreen mode Exit fullscreen mode

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") }
}
Enter fullscreen mode Exit fullscreen mode

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...
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 special TestScope 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 a Flow 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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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"])
    }
}
Enter fullscreen mode Exit fullscreen mode

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()) }
}
Enter fullscreen mode Exit fullscreen mode

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.

On to Part 3 ->

Top comments (0)