Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@CustomTestApplication value cannot be annotated with @HiltAndroidApp #2033

Closed
RobertBaruch opened this issue Aug 9, 2020 · 25 comments
Closed

Comments

@RobertBaruch
Copy link

I have an application that needs injection:

@HiltAndroidApp
class MyApplication: Application() {
    @Inject lateinit var printerFactory: PrinterFactory
    ...
}

Other code in the app uses application.printerFactory.

The printerFactory is provided by this:

@Module
@InstallIn(ApplicationComponent::class)
object ProdModule {
    @Provides
    fun providePrinterFactory(): PrinterFactory {
        return FactoryThatReturnsARealPrinter()
    }
}

Now, for instrumented tests, I need to override this with a test version:

@HiltAndroidTest
@UninstallModules(ProdModule::class)
@RunWith(AndroidJUnit4::class)
@LargeTest
class MainTest {
    @get:Rule(order = 0)
    var hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    var intentsTestRule = IntentsTestRule(MainActivity::class.java, false, false)

    // Test replacements for production stuff.
    @Module
    @InstallIn(ApplicationComponent::class)
    class TestModule {
        @Provides
        fun providePrinterFactory(): PrinterFactory {
            return FactoryThatReturnsATestPrinter()
        }
    }
    ...
}

And so I get this error:

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

Well, I can't use HiltTestApplication because I need to use MyApplication, which has dependencies injected.

So I added this:

    @CustomTestApplication(MyApplication::class)
    interface HiltTestApplication

And so I get this error:

error: [Hilt]
    public static abstract interface HiltTestApplication {
                           ^
  @CustomTestApplication value cannot be annotated with @HiltAndroidApp. Found: MyApplication

If MyApplication cannot be annotated with @HiltAndroidApp, then how is it expected to inject things?

Instructions unclear, ended up in inconsistent state.

@danysantiago
Copy link
Member

I think there is some room for improvement in the documentation, but the suggested approach here is to move your printerFactory into a base class that both your production app and the custom test app will extend.

class BaseApp : Application() {
   @Inject lateinit var printerFactory: PrinterFactory
}

Your prod app now just extends the new base:

@HiltAndroidApp
class MyApplication: BaseApp()

and your custom test app should too:

@CustomTestApplication(BaseApp::class)
interface HiltTestApplication

you'll also have to updates usages of your app, instead of casting the app context or app to your production app class cast it to the base class.

@RobertBaruch
Copy link
Author

Unfortunately that results in:

error: [Hilt]
    public static abstract interface HiltTestApplication {
                           ^
  @CustomTestApplication does not support application classes (or super classes) with @Inject fields. Found BaseApp with @Inject fields [printerFactory].

@danysantiago
Copy link
Member

Ah, my bad, I forgot about that check. :(

Added on ecd9e8f, you'll find the reason in the commit message. Due to injection timing we opted for banning injected fields in the test app instead of letting users run into NPEs due to injection not occurring in the App's onCreate() during a test.

There is probably a few paths you can take but if your intent is to scope and make printerFactory available to those who can get a hold of the app context, then maybe just scoping it (adding @Singleton to the provider) and creating an entry point for it might be a nice way make it available in a more on-demand fashion.

class BaseApp : Application() {
    fun getPrinterFactory() =
        EntryPointsAccessors.fromApplication(this, PrinterFactoryEntryPoint::class.java).getPrinterFactory()

    @EntryPoint
    @InstallIn(ApplicationComponent::class)
    interface PrinterFactoryEntryPoint {
        fun getPrinterFactory(): PrinterFactory
    }
}

@RobertBaruch
Copy link
Author

RobertBaruch commented Aug 11, 2020

Yes, that works. I ended up putting this code outside the application, but otherwise it works fine. I think this is major enough to deserve going in the documentation, because it's kind of a tripping hazard 😄

@rhonyabdullah
Copy link

rhonyabdullah commented Aug 28, 2020

Bad Solution
Regarding the this documentation I create interface and put my test application:

@CustomTestApplication(TestApplication::class)
interface AndroidTestApplication

But it gives me exception:

Caused by: java.lang.InstantiationException: java.lang.Class<com.myapp.AndroidTestApplication> cannot be instantiated
        at java.lang.Class.newInstance(Native Method)
        at android.app.Instrumentation.newApplication(Instrumentation.java:1165)
        at com.myapp..AndroidTestRunner.newApplication(AndroidTestRunner.kt:16)
        at android.app.LoadedApk.makeApplication(LoadedApk.java:1218)

So i'm directly passing the generated hilt application class for my test app and skip using AndroidTestApplication interface and it works:
Instrumentation.newApplication(AndroidTestApplication_Application::class.java, context)

@tianjyan
Copy link

@danysantiago I got the following issue when I use a base app. Do I missing something else ?

java.lang.RuntimeException: Unable to instantiate application com.sample.MainApplication: java.lang.InstantiationException: java.lang.Class<com.sample.di.TestApplication> cannot be instantiated

@CustomTestApplication(BaseApplication::class)
interface TestApplication
open class BaseApplication : Application()
@HiltAndroidApp
open class MainApplication : BaseApplication()

@bcorso bcorso added the P2 label Nov 5, 2020
@hgross
Copy link

hgross commented Feb 16, 2021

Is there any possible way to execute instrumented tests with the following structure using hilt 2.32-alpha?
It looks like there is no straight forward way with the @EntryPoint/@EntryPointAccessors pattern inside the BaseApplication. The structure works fine for the app, but not for instrumented tests with MyCustomTestApplication and MyCustomTestRunner.

I am able to run instrumented tests based on MyCustomTestApplication when I remove the entry point from BaseApplication.

Any clues how to get injection wiht hilt inside BaseApplication working?

Example:

// src
@CustomTestApplication(BaseApplication::class)
interface TestApplication
// src

open class BaseApplication : Application() {
    @EntryPoint
    @InstallIn(SingletonComponent::class)
    interface SomeDependencyEntryPoint {
        fun getSomeDependency(): ISomeDependency
    }

    // no @Inject, since "@CustomTestApplication does not support application classes (or super classes) with @Inject fields."
    // we make use of the "EntryPointAccessors-pattern" instead
    lateinit var someDependency: ISomeDependency

    override fun onCreate() {
        super.onCreate()

        someDependency = EntryPointAccessors.fromApplication(this, SomeDependencyEntryPoint::class.java).getSomeDependency()
        someDependency.doSomething();
    }
}
// src

@HiltAndroidApp
open class MainApplication : BaseApplication()
// androidTest src

@CustomTestApplication(BaseApplication::class)
interface MyCustomTestApplication

// A custom runner to set up the instrumented application class for tests.
// see https://developer.android.com/training/dependency-injection/hilt-testing
// must be referenced in the build.gradle
class MyCustomTestRunner : AndroidJUnitRunner() {
    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        // compilation errors in the IDE here are expected until first build (according to HILT docs)
        return super.newApplication(cl, MyCustomTestApplication_Application::class.java.name, context)
    }
}
// build.gradle of app module

android {
    defaultConfig {
        testInstrumentationRunner "eu.hgross.example.MyCustomTestApplication"
    }
}

@bcorso
Copy link

bcorso commented Feb 16, 2021

Hi @hgross,

We're currently working on a feature to allow entry points in such a case (with some caveats); however, it's still important to understand why this doesn't work by default in Hilt so that you can decide if this is something you really want to do.

First, if you have to reuse the BaseApplication in Gradle instrumentation tests be careful about mutable state because the same application instance is used for all test classes and test cases in Gradle instrumentation tests making it very easy to leak state across test cases. Instead, try moving all application state into the Hilt SingletonComponent. Each test case (even for Gradle instrumentation tests) will get a new instance of the SingletonComponent, so @Singleton scoped bindings will not be leaked across test cases.

In addition, try to avoid entry point calls in the application. While calling entry points lazily, as in #2033 (comment) sometimes works, it would better to provide it via a module instead, if possible.

That said, we do understand that there are some cases that required calling entry points in Application#onCreate(), e.g. some libraries require some static configuration. The main reason this doesn't work by default in Hilt is that at the time of Application#onCreate() there is no SingletonComponent available (remember that in Hilt tests, the SingletonComponent instance is created per test case rather than per Application). For these cases, we are working on an escape hatch to make it possible to use entry points from Applicaiton#onCreate() for special entry points.

@mhernand40
Copy link

For these cases, we are working on an escape hatch to make it possible to use entry points from Applicaiton#onCreate() for special entry points.

@bcorso are you referring to EarlyEntryPoints?

Instead, try moving all application state into the Hilt SingletonComponent.

What about the scenario where you need to @Inject some fields into the Application but those fields are also invoked within Application#onCreate as part of initialization logic? For example, a Set<LifecycleObserver> where in onCreate(), you wish to add them to the ProcessLifecycleOwner? Another example would be where a custom AppInitializer interface is defined and the Application gets injected with a Set<AppInitializer> or List<AppInitializer> and all AppInitializers need to be invoked during onCreate. It is not clear to me how these would apply to the statement above. 🤔

@bcorso
Copy link

bcorso commented Mar 2, 2021

@mhernand40, for now we still don't allow using @Inject fields in the application class (it's possible we allow this in the future, but that's questionable).

However, you can create an @EarlyEntryPoint to replace the @Inject fields for the application class, like:

class BaseApplication extends Application {
  // Use EarlyEntryPoint rather than @Inject for these fields since they need to be accessed in onCreate in tests.
  @EarlyEntryPoint
  interface ApplicationEarlyEntryPoint {
    Foo foo();
    Bar bar();
  }

  private Foo foo;
  private Bar bar;

  @Override
  public void onCreate() {
    super.onCreate();
    foo = EarlyEntryPoints.get(this, ApplicationEarlyEntryPoint.class).foo();
    bar = EarlyEntryPoints.get(this, ApplicationEarlyEntryPoint.class).bar();
    ... 
  }
}

While you could even just create an @EarlyEntryPoint injector by adding an inject method for the application, like: void inject(MyTestApplication app), it would lead to double injection if you use the base application in non-test applications.

@hgross
Copy link

hgross commented Mar 2, 2021

Thanks for the detailed answer and valuable testing hints @bcorso . One of the use cases @mhernand40 mentioned does apply to my requirements. I want to register a HILT-injected component to the lifecycle callbacks in the application class. This component is repsonsible to orchestrate initialization and teardown/shutdown.

Regarding the BaseApplication: This turns out to be a quite time consuming endavour for me as well, since I am dealing with a multi-module project and finding a structure to re-use the custom test-runner for instrumented tests in sub-/parent-modules is not at all straight forward to me. Is there any up-to-date multi-module best-practice project-example for multi-module projects and the whole testing pyramid (unit, instrumented/integration, ui-tests)?

@mhernand40
Copy link

Thanks for the suggestion @bcorso! Replacing @Inject fields with the EarlyEntryPoint seems to be working on my end. 🙂

@namgk
Copy link

namgk commented May 7, 2021

Although @EarlyEntryPoint works for injecting into the Application#onCreate, the same dependency if is injected to another Android component such as a Service seems produce two instances of such dependency (even though annotated with @singleton).

In my case I have something like this:

@Singleton
class Foo { @Inject constructor}

then in my BaseApplication I have this:

private Foo;

@EarlyEntryPoint
@InstallIn(SingletonComponent.class)
interface HiltEntryPoint { 
    Foo getFoo(); 
}

onCreate(){
    foo = EarlyEntryPoints.get(this, HiltEntryPoint.class).getFoo();
}

Then in one of my service I have:

@Inject
Foo foo;

The result is that the foo in my Service is different than the foo in my BaseApplication.

@bcorso
Copy link

bcorso commented May 7, 2021

@namgk, that's correct. The @Inject Foo field does not come from the EarlyEntryPoint component. If you want the early entry point Foo in your service you would have to also get it with an early entry point EarlyEntryPoints.get(this, HiltEntryPoint.class).getFoo().

However, you may be able to avoid this issue if it's possible for you to create Foo lazily rather than in Application#onCreate and just use a normal entry point, like:

  @EntryPoint
  @InstallIn(SingletonComponent.class)
  interface HiltEntryPoint { 
      Foo getFoo(); 
  }

  private Foo foo() {
    // Create this lazily rather than in onCreate to avoid needing an @EarlyEntryPoint
    return EntryPoints.get(this, HiltEntryPoint.class).getFoo();
  }

@namgk
Copy link

namgk commented May 7, 2021 via email

@bcorso
Copy link

bcorso commented May 7, 2021

I think the way hilt works with tests isn't always intuitive. Like one
instance of application but different instance of SingletonComponent.

Where or when, or how such SingletonComponents are created by the way, if
not tied to Application#onCreate?

A lot of the complexity around testing is due to how Gradle runs instrumentation tests. Gradle will run all tests using a single instance of an Application. This means Application#onCreate() gets called only once no matter how many tests and test cases you're running. It also means that storing any state in the application class will leak that state across all of your test cases. To avoid this issue, Hilt creates and stores the SingletonComponent using the HiltAndroidRule rather than the Application so that each test case gets its own component instance and is independent from other test cases.

However, as you've likely seen, using HiltAndroidRule to create the SingletonComponent causes issues if you try to call entry points from Application#onCreate() because the SingletonComponent has not yet been created (and even if you could create one, it's not clear which one you would use since each test case has its own?).

For cases where you absolutely need to access an entry point in Application#onCreate() we've created EarlyEntryPoint, but as you've noticed, the binding is created from a completely different component that has the lifetime of the Application rather than the HiltAndroidRule. In general, you should avoid using EarlyEntryPoint unless you have no other choice because it can lead to the issues you described of two instances of a singleton component.

For more details see https://dagger.dev/hilt/early-entry-point#background

@namgk
Copy link

namgk commented May 7, 2021 via email

@namgk
Copy link

namgk commented Jul 7, 2021 via email

@mattbusuu
Copy link

mattbusuu commented Aug 19, 2022

I've been trying to follow the steps above, but I'm getting the following error, when trying to access context from the application's onCreate.

MainApplication

@HiltAndroidApp
open class MainApplication : BaseApplication() {

HiltTestApplication

@CustomTestApplication(BaseApplication::class)
interface HiltTestApplication

BaseApplication

abstract class BaseApplication: Application()
lateinit var foo: Foo

@EarlyEntryPoint
@InstallIn(SingletonComponent::class)
internal interface ApplicationEarlyEntryPoint {
        val foo: Foo
}
        
override fun onCreate() {
        super.onCreate()
        foo = EarlyEntryPoints.get(this <---- ERROR HERE, ApplicationEarlyEntryPoint::class.java).foo
......

Results in:

Expected application context to implement GeneratedComponentManagerHolder. Check that you're passing in an application context that uses Hilt.

Is there a step I have missed somewhere? Thanks in advance!

@Chang-Eric
Copy link
Member

Can you check what your application class is when you're running? (Just throw in a break point or a print in there before you call EarlyEntryPoints)

@mattbusuu
Copy link

Thanks @Chang-Eric for the suggestion. That helped me to spot that there was some misconfiguration in the CustomTestRunner. I updated that and applied the suggestion above to use the MyApplication_Application and all is working.

copybara-service bot pushed a commit that referenced this issue Aug 19, 2022
…ent GeneratedComponentManager in EarlyEntryPoints.

Issue #2033.

RELNOTES=n/a
PiperOrigin-RevId: 468766554
copybara-service bot pushed a commit that referenced this issue Aug 19, 2022
…ent GeneratedComponentManager in EarlyEntryPoints.

Issue #2033.

RELNOTES=n/a
PiperOrigin-RevId: 468809118
@kyodgorbek
Copy link

kyodgorbek commented Oct 20, 2022

guys I am still getting following error

java.lang.IllegalStateException: Hilt test, com.chargeatfriends.android.ui.settings.login.SignInFragmentTest, cannot use a @HiltAndroidApp application but found com.chargeatfriends.android.Application. To fix, configure the test to use HiltTestApplication or a custom Hilt test application generated with @CustomTestApplication.
	at dagger.hilt.internal.Preconditions.checkState(Preconditions.java:83)
	at dagger.hilt.android.internal.testing.MarkThatRulesRanRule.<init>(MarkThatRulesRanRule.java:63)
	at dagger.hilt.android.testing.HiltAndroidRule.<init>(HiltAndroidRule.java:36)
	at com.chargeatfriends.android.ui.settings.login.SignInFragmentTest.<init>(SignInFragmentTest.kt:36)

@bcorso
Copy link

bcorso commented Oct 20, 2022

@kyodgorbek you will need to try the suggestions in the error message. If you have specific questions/issues please give more information.

@kyodgorbek
Copy link

@bcorso I am doing as suggested but test fails

@bcorso
Copy link

bcorso commented Oct 25, 2022

@kyodgorbek, you're going to have to give more information for us to help you.

  • Which of the suggestions did you try (there's two)?
  • What's the error message when you tried the suggested approach? Is it the same or a different stacktrace?
  • Can you provide code snippets or example project that reproduce the issue?

Also, since this issue is already closed, it's probably better to start a new issue with all of these details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests