If you're using the Jetpack Compose Navigation component with routes set up with arguments, you may be looking to use data passed in Compose route arguments in your MavericksState.

Note that if you're looking to consume Fragment arguments, then you should be looking at this.

Before we proceed, let's ensure we're on the same page w.r.t. the stack of libraries at play: Compose foundation for UI, navigation-compose for Navigation, hilt for DI, Mavericks for ViewModels and state (mavericks-compose and mavericks-hilt). In our case, we don't use Fragments in the project.

In this set up, you create your routes with NavGraphBuilder.composable under a NavHost, and your Compose Navigation route arguments will be available in the backStackEntry.arguments Bundle.

Set the argsFactory parameter for your mavericksViewModel call. The result from this function will be passed to your state constructor as a parameter when the ViewModel is first initialized. That return value needs to be a Parcelable.

val argsBundle = backStackEntry.arguments ?: return  // remove the Elvis return if arguments aren't mandatory

val viewModel: InvoiceViewModel = mavericksViewModel(argsFactory = { argsBundle })

Next, add a constructor to your MavericksState to accept a Bundle and build the initial state object from data in the Bundle:

In the above case, argsBundle of type Parcelable supplied in the argsFactory parameter of the mavericksViewModel call will be passed to InvoiceViewState's secondary constructor accepting the arguments: Bundle parameter.

Hilt, Mavericks: Writing Unit Tests for MavericksViewModel, Repository, DAO

Typically, a MavericksViewModel accepts the MavericksState along with Repositories which in turn contain DAOs. When using Hilt for DI, the most common challenge is how to create an instance of a repository to supply to the constructor of a MavericksViewModel. The solution is to use Hilt DI in tests.

Hilt also lets you easily swap the database implementation in tests and use a throwaway in-memory database to test persistence operations. You can also preload data in it.

@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [DatabaseModule::class],
)
@Module
object FakeDatabaseModule {
    @Provides
    fun provideEntityDao(database: Database) = database.entityDao()

    @Provides
    @Singleton
    fun provideDatabase(): Database {
        val context: Context = ApplicationProvider.getApplicationContext()
        return Room.inMemoryDatabaseBuilder(context, Database::class.java)
            .allowMainThreadQueries()
            .build()
    }
}

Testing MavericksViewModel

Create a base class for your tests. This registers two rules - one for Hilt and another for Mavericks. The Hilt rule allows you to use Hilt DI in test cases. The Mavericks test rule makes ViewModel state access synchronous, among many other things.

import com.airbnb.mvrx.test.MvRxTestRule
import dagger.hilt.android.testing.HiltAndroidRule
import org.junit.Before
import org.junit.Rule

abstract class BaseTest {

    @get:Rule(order = 0)
    val hiltRule by lazy { HiltAndroidRule(this) }

    @get:Rule(order = 1)
    val mvrxRule = MvRxTestRule()

    @Before
    fun init() {
        hiltRule.inject()
    }

}

Your test class for MavericksViewModel can be created as follows:


@HiltAndroidTest
class HomeViewModelTest : BaseTest() {

    @Inject
    lateinit var repository1: OneRepository

    @Test
    fun testHomeViewModel() = runBlocking {
        val viewModel = HomeViewModel(
            HomeViewState(),
            repository1,
        )

        /* actions, asserts code here */
    }
}

Testing DAOs

Similarly, DAO's can be tested as follows:

@HiltAndroidTest
class EntityDaoTest : BaseTest() {

    @Inject
    lateinit var entityDao: EntityDao

    @Test
    @Throws(Exception::class)
    fun insertAndGetEntity() = runBlocking {
        val entityId = 0
        val entity = Entity(
            0,
            "test://$entityId",
        )

        entityDao.insert(entity)
        val savedEntity = entityDao.entities().first()
        assertEquals(savedEntity[0].uri, entity.uri)
    }

}

Notes on Common Errors

Getting Mavericks to work with Hilt is easy. But you have to be mindful of the steps involved in the integration. For instance, not registering a MavericksViewModel with Hilt gives you the following cryptic error.

        at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:957)
        at android.os.Handler.handleCallback(Handler.java:938)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loop(Looper.java:223)
        at android.app.ActivityThread.main(ActivityThread.java:7656)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
     Caused by: java.lang.NullPointerException: null cannot be cast to non-null type VM of com.airbnb.mvrx.hilt.HiltMavericksViewModelFactory
        at com.airbnb.mvrx.hilt.HiltMavericksViewModelFactory.create(HiltMavericksViewModelFactory.kt:49)
        at [].ui.screen.invoice.InvoiceViewModel$Companion.create(Unknown Source:15)
        at [].ui.screen.invoice.InvoiceViewModel$Companion.create(InvoiceViewModel.kt:35)

The Fix

Background

In mobile apps, most P1 bugs exist in the state management space. Mavericks provides a good foundation to manage state in Android apps, especially for those familiar with the React way. With Jetpack Compose set up to do the rendering, it's helpful to think of a "screen" as a "function of the state". This makes the rendering aspect simple, while increasing the complexity of generating states based on user, service, or environment effects.

That complexity, however, can be managed by good practices, writing tests. You can now have two types of tests: one set that tests whether x state produces y UI effect, and another set testing if x condition produces y state. Those tests can be easily run with mocks and can be run in parallel to save time.

Another advantage of cleanly having mocks supply state to render a working UI is that you can build a demo app very quickly, without implementing any details and even without a backend service. That way, UX engineers can take early calls on what needs to change without involving too much engineering effort.

Now, most of what's possible in Mavericks should be possible with Hilt, and you'll definitely find it easier to get help with Hilt issues. But in my opinion, MavericksViewModel provides a simple and intuitive DX compared to HiltViewModel. I'd much rather use Hilt for DI alone. I'll mark this down to personal preference for now, but there's much more to it.

Never cross the streams