Blog Infos
Author
Published
Topics
Published

Testing is like eating. You have to test the right things and not do it too much. Photo by Sefa Ozel on iStock.

 

How do you unit test an MVVM app at scale without slowing your ability to refactor?

As mobile apps grow larger and architectures evolve, tech debt creeps in and continuous refactoring becomes critical. At the same time, unit tests are essential to drive the implementation, prevent regressions, and provide documentation. At a certain point they will become an obstacle to refactoring if not done properly. This is a lesson we learned over the years at PSS, publisher of SCRUFF and Jack’d.

In this article, we’ll present how Behavior-Driven Development provides the solution to this problem. You do not need to write a test for every single function on every single class. The only thing you need to test is a user story — the behavior that a user will perform and the outcome that they will perceive.

Just like Michael Pollen’s famous diet wisdom — “Eat food. Not too much. Mostly plants.” — if we were to think about testing like eating, and refactoring like exercise, this would be our diet:

Write tests. Not too much. Mostly on the ViewModel.

Modern mobile app architecture

Let’s start with a quick overview of a typical MVVM or MVI architecture:

An MVVM or MVI architecture that enforces Clean Architecture

  • View: Captures user intents and updates the UI by observing the state from the ViewModel
  • ViewModel: Holds the state of the View and communicates with the domain layer
  • UseCase: The domain layer; encapsulates the business logic of our app and requests data from the repositories
  • Repository: Fetches data either from a remote or local data source and exposes them to our domain layer. It only talks to abstractions so that it won’t violate the Dependency Inversion Principle.
  • Data Source: Can access directly a local database or make a network request. They are the frameworks that fall into the outermost layer of Clean Architecture.

The question now is how do you structure your tests in this architecture? Since this article is about unit testing, the View and Data Source layers are out of scope. Those can be tested in integration tests, UI tests, or end-to-end tests, but those tests should only be complementary as they are generally slow and expensive to maintain.

Of course each type of test has its own spot in the testing pyramid but in this article we want to maximize the return-on-investment in testing. We want to get the most value out of our tests by covering the core layers of this architecture with the least code possible to write and maintain.

How do you test the ViewModel, UseCase, and Repository layers?

Martin Fowler identifies two approaches in unit testing, the classical and mockist testing:

“The classical TDD style is to use real objects if possible and a double if it’s awkward to use the real thing. A mockist TDD practitioner, however, will always use a mock for any object with interesting behavior.”

Let’s see how each of these two approaches would look in a real-world application.

Real-world example: Chat application

Imagine you’re working on a chat application and you’re building the screen for chatting with a user. The product team has instructed you to display the existing list of chat messages when you first open the screen and to update that list when you send a new message. Let’s see the actual code on each of the layers:

class ChatViewModel(
    private val getChatMessagesUseCase: GetChatMessagesUseCase,
    private val sendTextMessageUseCase: SendTextMessageUseCase,
    private val user: User
) : BaseViewModel() {

    data class State(
        val messages: List<MessageUiModel>
    )

    val state: Observable<State>
        get() = getChatMessagesUseCase(user).map {
            val messageUiModelList = MessageUiModelMapper.fromMessagesList(it)
            State(messageUiModelList)
        }

    fun onTextMessageSent(text: String) {
        sendTextMessageUseCase(text, user).safeSubscribe()
    }
}

The ChatViewModel gets the list of messages from the GetChatMessagesUseCase and transforms it into a State that the View layer observes to render the screen. A MessageUiModelMapper is used to take the Message domain model and transform it into a MessageUiModel. When the user sends a text message, it’s passed into the respective UseCase to handle.

class GetChatMessagesUseCase(
    private val chatMessagesRepository: ChatMessagesRepository
) {

    operator fun invoke(user: User): Observable<List<Message>> {
        return chatMessagesRepository.getChatMessages(user).map { messagesList ->
            messagesList.sortedBy { it.date.time }
        }
    }
}

The GetChatMessagesUseCase observes the chat messages from the ChatMessagesRepository and emits them after sorting them by date.

class SendTextMessageUseCase(
    private val chatMessagesRepository: ChatMessagesRepository
) {

    operator fun invoke(text: String, user: User): Completable {
        return if (isValid(text)) {
            val message = MessageFactory.fromText(text)
            chatMessagesRepository.sendChatMessage(message, user)
        } else {
            Completable.error(InvalidMessage())
        }
    }

    private fun isValid(text: String): Boolean {
        return text.length < 180
    }
}

The SendTextMessageUseCase takes a text, checks if it’s valid, and then transforms it into a Message using the MessageFactory and sends it to the ChatMessagesRepository.

class ChatMessagesRepository(
    private val chatDataSource: ChatDataSource
) {

    private val cachedChatMessages: BehaviorSubject<List<Message>> = BehaviorSubject.create()

    fun getChatMessages(user: User): Observable<List<Message>> {
        return if (cachedChatMessages.hasValue()) cachedChatMessages else {
            loadExistingChatMessages(user).andThen(cachedChatMessages)
        }
    }

    fun sendChatMessage(message: Message, user: User): Completable {
        return chatDataSource.sendChatMessage(message, user)
            .doOnSubscribe {
                cachedChatMessages.onNext(getCurrentMessagesList() + message)
            }.doOnComplete { /* .. */ }
    }

    private fun loadExistingChatMessages(user: User): Completable {
        return chatDataSource.getChatMessages(user)
            .doOnSuccess {
                cachedChatMessages.onNext(it)
            }.ignoreElement()
    }

    private fun getCurrentMessagesList(): List<Message> {
        return cachedChatMessages.value ?: emptyList()
    }
}

The ChatMessagesRepository emits the cached list of messages. It’s also responsible for loading the initial list of messages and for a sending a new chat message through the ChatDataSource.

interface ChatDataSource {
    fun getChatMessages(user: User): Single<List<Message>>
    fun sendChatMessage(message: Message, user: User): Completable
}

The ChatDataSource is an interface that hides the implementation for getting and sending chat messages. One of its implementations could be a ChatRemoteDataSource that’s using Retrofit to make the network requests.

Now, let’s see how the unit tests of those layers would look in the two testing approaches that we identified.

The mockist approach: Mocking at every layer

Note that by “mock” we refer to any kind of test double — whether that’s a fake, a stub, or a mock.

Let’s take a look at the following example:

You have a Class A depending on Class B & Class C. You’re testing Class A by mocking Class B and Class C. Then you realize that the design becomes simpler if you refactor by moving Class C under Class B. Now tests in Class A are broken, despite not changing its external behavior.

The same applies in our chat application example. If we decide to mock at every layer, then the ViewModel test would have to mock each one of its dependencies:

private fun mockUseCases() {
    every { getChatMessagesUseCase(user = User(id = 1)) } returns { /* ... */ }
    every { sendTextMessageUseCase(text = "hello", user = User(id = 1)) } returns { /* ... */ }
}

The same applies for the UseCases. They would all have to mock the repository:

private fun mockRepository() {
    every { chatMessagesRepository.getChatMessages(user) } returns { /* ... */ }
    every { chatMessagesRepository.sendChatMessage(message, user) } returns { /* ... */ }
}

This pattern makes each test to be tied to the actual implementation. Why do I need to know the dependencies of the ViewModel in order to test it? I want to test the feature by providing an input and evaluating the output. By mocking every dependency, we’re not testing the behavior as we want in BDD, we’re just testing the internal implementation.

Our tests become “overspecified” which means that they contain assumptions on how the code under test is implemented, causing the tests to break if the implementation changes.

At the same time, we would have to create four test suites; one for the ViewModel, one for each of its UseCases and one for the Repository. That means a lot more code to write and maintain without any clear benefit. Imagine doing that in even more complex ViewModels with more dependencies and in UseCases that depend on more than one repositories.

Every mock is a commitment that this is the implementation that we’re going to stick with.

Mocks bind you to a specific dependency graph.

Therefore, refactoring will become much harder. For example, it’s very likely that at some point we might want to extract some of the responsibility of the ViewModel and move it into a separate UseCase or we might want to merge two similar UseCases into one. However the tests will be an obstacle, as they will break in the process even though the external behavior did not change.

Let’s see Martin Fowler’s definition of refactoring in his book “Refactoring: Improving the Design of Existing Code”:

“Refactoring is the process of changing a software system in such a way that it does not alter the external behavior of the code yet improves its internal structure.”

With mocking at every layer, any refactoring attempt will have to update the tests and the mocks in each of the layers. Not only this means extra work for engineers, but more importantly it’s error-prone as the tests will have to be updated each time and eventually this might discourage a team from seeking continuous refactoring.

What do the experts say?

What do the most recognized and experienced software engineers with unit tests, including Martin FowlerUncle BobKent Beck, and Ian Coopersay about mocks? (Spoiler alert: 🤨)

Supple design

In the end, we should always try to design a system that is easy to change, or as Domain-Driven Design defines it, “supple design”. Heavy mocking causes the opposite effect — it makes the system hard to change. And a system that is hard to change is also hard to refactor, which will essentially make a codebase degrade over time.

Mocking is like sugar — you can enjoy it now and then, but it’s a rush of short-term productivity that will cause lethargy later on when you are unable to refactor.

Now that we identified the problems with mocking, let’s see how Behavior-Driven Development can provide the solution.

The BDD approach: Testing on the ViewModel

One interesting question someone might ask is for which layer of the application we should write our tests. In BDD, we want to test what our application is offering to users consuming it — what is the requirement and not how we implement that requirement. And that contract lives in the ViewModel layer.

So since we’ll test the ViewModel, does that mean that our UseCases and Repository will be untested? No and that’s the key. Those layers will be covered for free by the BDD tests of the ViewModel. In other words, our UseCases and Repositories can have any implementation we wish. As long as all scenarios are tested in the ViewModel layer, this guarantees that the code in the inner layers will work as expected, so there’s no need to separately test them.

The system under test is not the class itself, it’s the feature or the module in which it’s contained.

Let’s take a look at the BDD test suite of our ChatViewModel:

class ChatViewModelSpec : BehaviorSpec(), KoinTest {

    private val viewModel: ChatViewModel by inject()

    init {
        beforeSpec { fakeDataSourceResponse() }

        Given("I am in chat") {
            val stateObserver = viewModel.state.test()

            Then("The existing messages are displayed") {
                stateObserver.lastValue() shouldBeEqualTo ChatViewModel.State(
                    messages = listOf(MessageUiModel(text = "1st message", type = Message.Type.Received))
                )
            }

            When("I send a text message") {
                var textMessage = ""
                beforeEach {
                    viewModel.onTextMessageSent(textMessage)
                }

                And("It is valid") {
                    textMessage = "2nd message"

                    Then("It is displayed") {
                        stateObserver.lastValue() shouldBeEqualTo ChatViewModel.State(
                            messages = listOf(
                                MessageUiModel(text = "1st message", type = Message.Type.Received),
                                MessageUiModel(text = "2nd message", type = Message.Type.Pending)
                            )
                        )
                    }
                }

                And("It is not valid") {
                    textMessage = (1..180).joinToString("") { "a" }

                    Then("It is not displayed") {
                        stateObserver.lastValue() shouldBeEqualTo ChatViewModel.State(
                            messages = listOf(MessageUiModel("1st message", Message.Type.Received))
                        )
                    }
                }
            }
        }
    }

    private fun fakeDataSourceResponse() {
        get<FakeChatDataSource>().chatMessagesResponse = listOf(Message("id", "1st message", Date(), Message.Type.Received))
    }
}

Note that we’re using the Kotest framework to take advantage of the Behavior Spec and structure our tests in a Given / When / Then format, but the exact same tests can be written in JUnit 5 with a bit of a boilerplate code for defining all nested classes.

The BDD tests are written in Gherkin and they reflect the exact requirements that the product team specified:

Feature: Chat screen

Scenario: The user enters chat
  Given I am in chat  
  Then The existing messages are displayed

Scenario: The user sends a text message
  Given I am in chat  
  When I send a text message
  And It is valid
    Then It is displayed
  And It is not valid
    Then It is not displayed

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Intro to unit testing coroutines with Kotest & MockK

In this workshop, you’ll learn how to test coroutines effectively using the Kotest and MockK libraries, ensuring your app handles concurrent tasks efficiently and with confidence.
Watch Video

Intro to unit testing coroutines with Kotest & MockK

Jaroslaw Michalik
Kotlin GDE

Intro to unit testing coroutines with Kotest & MockK

Jaroslaw Michalik
Kotlin GDE

Intro to unit testing coroutines with Kotest & MockK

Jaroslaw Michali ...
Kotlin GDE

Jobs

If you run the test suite above with coverage, you’ll notice that it’s covering 100% of the code in the ViewModel, in its UseCases and in the Repository, including their dependencies such as the MessageUiModelMapper and the MessageFactory. That means that the core layers of this feature are 100% tested in a single test suite.

Is this a unit test or an integration test?

To give insight on this interesting and debatable topic, we’ll first leverage the wisdom of the experts in the field:

Kent Beck in his book “Test-Driven Development: By Example” defines unit tests as tests that can run independent of one another. The unit of isolation is the test and not the classes under test.

Martin Fowler defines a distinction between sociable tests and solitary tests in his excellent articles on unit tests and on the “misshapen” test pyramid. Sociable unit tests are the ones where the tested unit relies on other classes to fulfill its behavior. Ultimately he suggests that the team should decide what makes sense to be a unit for the purposes of their understanding of the system and its testing.

Ian Cooper on his eye opening talk TDD, Where Did It All Go Wrong defines a unit as an isolated module — a black box where you talk to what is exposed. He emphasizes that adding a new class is not the trigger for writing tests — the trigger is implementing a requirement, something much higher up. And that is where you write the tests.

In other words:

Unit tests were never about a single class. It’s all a big misunderstanding.

In an MVVM or MVI architecture, the “module” is the actual feature that we’re testing and what’s exposed is the user intent, i.e. the behavior that the user will perform (input), and the state that the app will be in after that behavior is consumed (output).

The black box / module / unit of BDD testing. It takes an input (behavior) and it evaluates the output (state).

And that’s why we consider it a unit test. It doesn’t talk to the database, to the network, or to the file system. It does not require the emulator to run, it is fast and deterministic.

Is testing the ViewModel sufficient to test all layers or do I need additional class-specific unit tests?

By testing the ViewModel in each feature, the tests will also cover the code in the underlying UseCases and Repositories, as long as they capture all scenarios of that feature. However, you might have repositories in your codebase that are not tied to a specific feature, such as for example the AccountRepository that stores & retrieves the user’s account, or the LocationRepository that emits the current device location. Depending on their complexity, it might be worth testing those classes separately.

When to mock?

Mocks were certainly introduced for a reason and they do have a place in our BDD proposal. When writing unit tests, we recommend mocking or faking things that you cannot control or you do not own, or when it’s expensive to use the real implementation. That includes databases, network calls, third-party services, and anything else that belongs to the outermost layer of the Clean Architecture. In the BDD test suite of our ChatViewModel, the only thing we faked was the response of the data source.

Another good use case for mocking is when using it as a temporary workaround to deal with legacy code that you want to gradually refactor and test, until some of its dependencies can be removed.

Benefits of BDD tests

We have witnessed huge benefits after fully embracing BDD over the last couple of years. And we believe that the more you use it and gain experience with, the more you realize its power:

  • The biggest one is that we test the behavior and not the implementation. If you noticed in our BDD test suite we’re only injecting the ChatViewModel and not the UseCases or the Repository. This means that our tests are decoupled from the actual implementation and the implementation can change without updating the tests. Thus, we enable refactoring. The UseCase and Repository layers can be refactored and the tests should still pass. This is significant because the tests will not be an obstacle in continuous refactoring — an essential practice in Domain-Driven Design and Extreme Programming (XP) to continuously improve the design of a system and the health of a codebase.
  • We enable TDD. We start with a failing test and we then write the minimum implementation required to make the test pass. And only then we refactor. That means that we can start pushing code to the UseCase or Repository layers without updating the tests and without having to maintain a different test suite for each class.
  • The tests act as the documentation of the actual feature. Our code is the only source of truth of how a feature should behave and that’s the only source that we have to maintain. Having the code as documentation is another important principle in Domain-Driven Design and XP.
  • There is significantly less code to write and maintain which can increase the productivity and velocity of an engineering team.
  • Product teams, engineering teams, and QA teams speak the same language which uses the Gherkin syntax. This way the product specifications become easier to write and understand and they can be used as the starting point for developing the feature using BDD & TDD.
Summary

Testing is like eating: you have to do it to survive, but if you do it too much or test the wrong things, there are bad consequences for your project’s health. And just like “food comas”, you can find yourself in a “testing coma” where hundreds of tests can break with a simple refactoring, which will halt code progress. Refactoring is like exercise: it never ends and you want to do a little every day instead of a bunch all at once and then never again.

Our Unit Testing Diet provides a healthy way to test and refactor an app at scale, by testing the behavior on the ViewModel layer and not the internal implementation, using the real objects for the inner layers and not mocks.

Next up: Setting up the state in tests

If mocking at every layer undermines our ability to refactor, how do we simulate the state in our tests, i.e. the “Given” in BDD? In Part II of the Unit Testing Diet we will introduce Test Factories, an essential tool to set up common state and keep your code DRY in tests.

About the author

Stelios Frantzeskakis is a Staff Engineer at Perry Street Software, publisher of the LGBTQ+ dating apps SCRUFF and Jack’d, with more than 30M members worldwide.

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Proxy is a wrapper for an object that is used behind the scenes. This…
READ MORE
blog
In this article, we explore the challenges of testing ViewModels and the problems associated…
READ MORE
blog
Testing is an essential part of the software development process. A unit test generally…
READ MORE
blog
Recently, Android studio Flamingo 🦩 hit the stable channel. So I have updated the…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu