androidnavigationandroid-jetpack-composeandroid-testingdagger-hilt

AndroidTest Compose IllegalStateException: Given component holder class androidx.activity.ComponentActivity does not implement interface dagger.hilt


I've seen a few things now where you are trying to inject a viewModel using hilt navigation and it is giving errors. I'm using Hilt Navigation with Compose and I'm having problems with doing UI tests.

This question is the most similar.

I have an abstract viewModel which is being inherited by 5 different viewmodels and the viewModel is scoped to the backstack. Everything works fine on the emulator and an real device, just not in UI tests.

Here is the abstract viewModel:

abstract class ProposeNewGoalViewModel : ViewModel() {

    private val proposedGoalUiDataInit = ProposedGoalUiData(
        targetAmount = null,
        startYear = LocalDateTime.now().year.toString(),
        singleOrRecurring = SingleOrRecurring.SINGLE,
        goalName = null,
        wantWishOrNeed = WantWishOrNeed.NEED,
        yearCareProvidedStart = LocalDateTime.now().year.toString()
    )

     open fun resetState() { 
        ......
     }

//methods
.......

}

Here is one of the viewModels:

@HiltViewModel
class Category1OneTimeOrRecurringViewModel @Inject constructor() : ProposeNewGoalViewModel() {

    override fun resetState() {
        super.resetState()
        showRecurrenceFormFields.update { false }
        onTargetAmountChanged("")
        onStartYearChanged("")
        onFrequencyChanged("")
        onNumberOfOccurrencesChanged("")
    }
}

So nothing earth shattering. Here's where I get the error in my androidTest:

@AndroidEntryPoint
class GoalsFragment() { 

@AndroidEntryPoint
class GoalsFragment : BaseFragment() {

.......

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    return ComposeView(requireActivity()).apply {
        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        setContent {
            GoalsNav(
                goalsViewModel = goalsViewModel,
                proposeNewGoalViewModel = proposeNewGoalScreenViewModel,
                popBackStack = { activity?.finish() },
                navigateToAccounts = {
                    landingViewModel.performNavigationSubject.onNext(R.id.accounts)
                },
                navigateToMyTeamMessage = { messageType ->
                    findNavController().navigateSafe(
                        R.id.action_goalsLanding_to_myTeamNewMessage,
                        MyTeamNewMessageFragmentArgs(
                            messageType
                        ).toBundle()
                    )
                },
                fragmentNavController = findNavController()
            )
        }
    }
}
}

@SuppressLint("NavigateSafe")
@Composable
fun GoalsNav(
    goalsViewModel: GoalsViewModel,
    proposeNewGoalViewModel: ProposeNewGoalScreenViewModel,
    navController: NavHostController = rememberNavController(),
    popBackStack: () -> Unit,
    navigateToAccounts: () -> Unit,
    navigateToMyTeamMessage: (MyTeamMessageType) -> Unit,
    fragmentNavController: NavController
) {
    EJTheme {
        NavHost(
            navController = navController,
            startDestination = GOALS_ROUTE
        ) {

        ////////////////////THIS WORKS FINE IN TEST    
        composable(route = PROPOSE_NEW_GOAL_ROUTE) { backStackEntry ->
            ProposeNewGoalScreen(
                viewModel = hiltViewModel<ProposeNewGoalScreenViewModel>(backStackEntry),
                navigateBack = { navController.popBackStack() },
                onNavigateToGoalType = {
                    navController.navigate(
                        "$FILL_OUT_PROPOSED_NEW_GOAL_ROUTE/${it.goalType}"
                    )
                },
            )}
        
        /////////////////ILLEGAL STATE
        composable(route = "$FILL_OUT_PROPOSED_NEW_GOAL_ROUTE/{$PROPOSE_GOAL_TYPE_ARG}") { backStackEntry ->

            val goalTypeArg =
                backStackEntry.arguments?.getString(PROPOSE_GOAL_TYPE_ARG)?.toInt()
            val selectedGoalTypeEnum = goalTypeArg?.let { GoalType.getGoal(it) }
            val proposeNewGoal = selectedGoalTypeEnum?.let { ProposeNewGoal.getGoalBasedOnGoalType(it) }
            val proposeNewGoalViewModelForForm = when (proposeNewGoal?.category) {
                Category.CATEGORY_1_SINGLE_OR_REOCCURRING -> {
                    hiltViewModel(backStackEntry) as Category1OneTimeOrRecurringViewModel. ///////error happens here
                }
                Category.CATEGORY_2_EDUCATION ->  {
                    hiltViewModel(backStackEntry) as Category2EducationViewModel
                }
                Category.CATEGORY_3_SINGLE_OCCURRENCE -> {
                    hiltViewModel(backStackEntry) as Category3SingleTimeViewModel
                }
                Category.CATEGORY_4_PROVIDE_CARE -> {
                    hiltViewModel(backStackEntry) as Category4ProvideCareViewModel
                }
                Category.CATEGORY_5_BEQUEST -> {
                    hiltViewModel(backStackEntry) as Category5LeaveBequestViewModel
                }
                null -> hiltViewModel(backStackEntry) as Category1OneTimeOrRecurringViewModel
            }
            ProposeNewGoalForm(
                viewModel = proposeNewGoalViewModelForForm,
                navigateBack = { navController.navigate(PROPOSE_NEW_GOAL_ROUTE) { popUpTo(PROPOSE_NEW_GOAL_ROUTE) { inclusive = true } } },
                onNavigateBackToGoals = navigateToGoals,
                validateForm = {
                    proposeNewGoalViewModelForForm.validateForm()
                },
                selectedGoalType = goalTypeArg,
                onNavigateToConfirmation = { navController.navigate("$FILL_OUT_PROPOSED_NEW_GOAL_ROUTE_CONFIRMATION/$goalTypeArg") },
            )
        }
}
}

I tried making the abstract class an open class with HiltViewModel and no dice. I think it's a bug.

Test SetUp

@HiltAndroidTest
class ProposeNewGoalNavigationTests {

    lateinit var navController: TestNavHostController

    @get:Rule
    val composeTestRule = createComposeRule()

    private val context: Context
        get() = ApplicationProvider.getApplicationContext()

    private val goalsRepository: GoalsRepo = mock()
    private val featureTogglesRepo: FeatureTogglesRepo = mock()
    private val userDataRepo: UserDataRepo = mock()
    private val staticContentRepo: StaticContentRepository = mock()
    private val analyticRepository: AnalyticsRepo = mock()
    private val documentsRepo: DocumentsRepo = mock()
    private val messagesRepository: MyTeamMessagesRepository = mock()

    private val documentUseCase = DocumentDownloadUseCase(
        documentsRepo,
        mock(),
        "EJFileAbsolutePath"
    )
    private val launchDarklyFlagsRepo: LaunchDarklyFlagsRepo = mock()

    @get:Rule
    val setupRepoRule = SetupRepoRule()

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @Mock
    val dispatcherProvider: DispatcherProvider = Mockito.mock()

    val goalsRepo: GoalsRepo = mock()

    @BindValue
    val proposeNewGoalScreenViewModel = ProposeNewGoalScreenViewModel()

   @BindValue
   val confirmVM = ConfirmNewProposedGoalViewModel(
    goalsRepository = goalsRepository,
    userDataRepo = userDataRepo,
    featureTogglesRepo = featureTogglesRepo,
    messagesRepository = messagesRepository,
    dispatcherProvider = dispatcherProvider
    )

    @BindValue
    val category1FormVM = Category1OneTimeOrRecurringViewModel()

    @BindValue
    val category2FormVM = Category2EducationViewModel()

    @BindValue
    val category3FormVM = Category3SingleTimeViewModel()

    @BindValue
    val category4FormVM = Category4ProvideCareViewModel()

    @BindValue
    val category5FormVM = Category5LeaveBequestViewModel()

    @Before
    fun setupNavHost() {
        hiltRule.inject()
         
   

    val goalViewModel = GoalsViewModel(
        goalsRepository = goalsRepository,
        featureTogglesRepo = featureTogglesRepo,
        userDataRepo = userDataRepo,
        savedStateHandle = SavedStateHandle(mapOf(GoalsViewModel.ON_TRACK_CALCULATED to false)),
        dispatcher = Dispatchers.IO,
        analyticsRepository = analyticRepository,
        staticContentRepo = staticContentRepo,
        documentDownloadUseCase = documentUseCase,
        launchDarklyFlagsRepo = launchDarklyFlagsRepo
        )

        composeTestRule.setContent {
            navController = 
    TestNavHostController(LocalContext.current)
        
 navController.navigatorProvider.addNavigator(DialogNavigator())      
 
navController.navigatorProvider.addNavigator(ComposeNavigator())
            GoalsNav(
                goalsViewModel = goalViewModel,
                proposeNewGoalViewModel = 
 proposeNewGoalScreenViewModel,
                navController = navController,
                popBackStack = {},
                navigateToAccounts = {},
                navigateToMyTeamMessage = {},
                fragmentNavController = rememberNavController()
            )
        }
    }
}

Solution

  • 2025 UPDATE:

    app/src/androidTest/.../MyTest.kt

    @RunWith(JUnitParamsRunner::class)
    @HiltAndroidTest
    class MyKtTest {
    
        @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
        @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
    
        @Test
        @Parameters(value=[
            "false, true",
            "true,  false",
        ])
        fun fooTest(flag1: Boolean, flag2: Boolean) {
            ...
    
    

    We need @HiltAndroidTest and two rules instead of just val composeTestRule = createComposeRule(), and we heed to create HiltComponentActivity in our project.

    Fixes the following error: java.lang.IllegalStateException: Given component holder class androidx.activity.ComponentActivity does not implement interface dagger.hilt.internal.GeneratedComponent or interface dagger.hilt.internal.GeneratedComponentManager


    app/src/androidTest/.../HiltComponentActivity.java

    package...
    
    import androidx.activity.ComponentActivity;
    import dagger.hilt.android.AndroidEntryPoint;
    
    @AndroidEntryPoint
    public class HiltComponentActivity extends ComponentActivity {
    }
    

    It's just a ComponentActivity annotated with @AndroidEntryPoint


    app/src/debug/AndroidManifest.xml

    <?xml version="1.0" encoding="utf-8"?>
    <manifest xmlns:android="http://schemas.android.com/apk/res/android">
    
        <application
            android:icon="@mipmap/ic_myicon"
            >
            <activity
                android:name=".HiltComponentActivity"
                android:exported="true"
                android:theme="..."
                android:configChanges="..."
                >
                <intent-filter>
                    <action android:name="android.intent.action.MAIN" />
                    <category android:name="android.intent.category.LAUNCHER" />
                </intent-filter>
            </activity>
        </application>
    </manifest>
    
    

    Without this, you will get: java.lang.RuntimeException: Unable to resolve activity for: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=....HiltComponentActivity }


    app/src/androidTest/.../CustomTestRunner.kt

    package...
    
    import android.app.Application
    import android.content.Context
    import androidx.test.runner.AndroidJUnitRunner
    import dagger.hilt.android.testing.HiltTestApplication
    
    // A custom runner to set up the instrumented application class for tests.
    class CustomTestRunner : AndroidJUnitRunner() {
    
        override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
            return super.newApplication(cl, HiltTestApplication::class.java.name, context)
        }
    }
    

    app/build.gradle.kts

    ...
    android {
        ...
        defaultConfig {
        ...
            testInstrumentationRunner = "com.mypackagename.CustomTestRunner"
    
    

    (without this, you will get

    junit.framework.AssertionFailedError: No tests found in ...

    or

    java.lang.IllegalStateException: Hilt test, ..., cannot use a @HiltAndroidApp application but found .... To fix, configure the test to use HiltTestApplication or a custom Hilt test application generated with @CustomTestApplication.

    )

    and

    dependencies {
        androidTestImplementation("com.google.dagger:hilt-android-testing:2.56.2")
        kspAndroidTest("com.google.dagger:hilt-android-compiler:2.56.2")
    

    Without this, you will get "Unresolved reference 'testing'." at import dagger.hilt.android.testing.HiltTestApplication