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

Hilt: handle Services with injection automatically started in instrumentation tests #2016

Closed
francescocervone opened this issue Jul 31, 2020 · 18 comments

Comments

@francescocervone
Copy link

francescocervone commented Jul 31, 2020

Let's assume I have a service that is automatically started at the app startup and it is required by some third party library.
This service needs some fields to be injected in the onCreate method.

@AndroidEntryPoint
class MyMessagingService : FirebaseMessagingService() {
  @Inject internal lateinit var dep1: Dep1
  @Inject internal lateinit var dep2: Dep2
  ...
}

Then I have a @HiltAndroidTest that runs hiltRule.inject() in its @Before function.

@AndroidEntryPoint
class MyTest {
  @get:Rule val hiltRule = HiltAndroidRule(this)
  @Before fun inject() {
    hiltRule.inject()
  }
  ...
}

Unfortunately, the way Hilt injection works in tests generates a race condition between hiltRule.inject() and MyMessagingService#onCreate. If the latter gets executed before hiltRule.inject() the test will crash because dependency graph has not been created yet.

How can I avoid this issue?

@bcorso
Copy link

bcorso commented Jul 31, 2020

Let's assume I have a service that is automatically started at the app startup

Does "automatic" here mean that it's implicitly started without any way for you to control it? Or is there some initialization method you call at app startup that triggers it?

@francescocervone
Copy link
Author

Does "automatic" here mean that it's implicitly started without any way for you to control it?

Exactly

@bcorso
Copy link

bcorso commented Jul 31, 2020

Does that mean just adding the library to your dependencies list will trigger this Service to start up in your app without any call from your app? (Sorry, if these are dumb questions -- my Android knowledge is limited).

@francescocervone
Copy link
Author

Yes. A pretty common use case is a ContentProvider defined by the library to initialize its state. They are automatically instantiated without any developer interaction.

@bcorso
Copy link

bcorso commented Jul 31, 2020

Ah, I was aware of ContentProvider being automatically instantiated by the OS but not Service, but now I think I get it -- the library is starting their services "automatically" for the user by starting them in ContentProvider#onCreate().

Unfortunately, there's not much we can do here because creating the Hilt components in tests require an instance of the tests. The issue is that ContentProvider#onCreate() and even Application#onCreate() are called before the test runners create an instance of the test, so we can't create the component at that point.

Sorry, I don't have a great solution but here are a couple ideas:

  1. Try to delay when the Service requests the component by using a regular service (not annotated with @AndroidEntryPoint) that uses EntryPoints to get the bindings. Of course, this only helps if the entry points are guaranteed to be accessed only after the test is instantiated.
  2. If the library is not actually needed in the test, try to exclude it (not sure how difficult this would be in Gradle).
  3. If the library is not actually needed in the test, see if the library can provide a way to disable starting the Services in tests
  4. Provide a way for users to initialize the library themselves rather than doing it automatically so that you can control when the service is started in tests.
  5. Remove Hilt from the specific Services that are started from ContentProvider#onCreate()

@Chang-Eric
Copy link
Member

Unfortunately, there's not much we can do here because creating the Hilt components in tests require an instance of the tests.

Just wanted to chime in to expand on this and explain that requiring the test instance is needed to provide things like @BindValue. So the issue is if you imagine your Service was depending on a @BindValue provided value (which is just a field in the test), we can't actually give you the injections at that point because it has run too early.

@francescocervone
Copy link
Author

Try to delay when the Service requests the component by using a regular service (not annotated with @androidentrypoint) that uses EntryPoints to get the bindings

This suggestion should solve our issue! I always forget about how the EntryPoints feature can be useful is such cases.

I was hoping in a solution that didn't require a change in the production code but I think it's a small cost for a use case like this.

Thanks!

@sjaramillo10
Copy link

Hey @francescocervone, I am facing the same issue. Just to make sure, what you did is something like this?

// Remove this @AndroidEntryPoint
class MyMessagingService : FirebaseMessagingService() {
  
  @EntryPoint
  @InstallIn(ApplicationComponent.class)
  public interface MyMessagingServiceInterface {
    Dep1 getDep1();
    Dep2 getDep2();
  }

  @Override public void onMessageReceived(RemoteMessage remoteMessage) {
    // Make sure we gather all the required dependencies
    MyMessagingServiceInterface myMessagingServiceInterface = EntryPoints.get(getApplicationContext(), MyMessagingServiceInterface.class);
    Dep1 dep1 = myMessagingServiceInterface.getDep1();
    Dep2 dep2 = myMessagingServiceInterface.getDep2();
    ...
  }

  // Probably something similar in onNewToken()
  ...
}

@francescocervone
Copy link
Author

@sjaramillo10 yes but it doesn't solve the problem. There is still a race condition that makes the test crash when onNewToken gets invoked.

@sjaramillo10
Copy link

sjaramillo10 commented Sep 1, 2020

@francescocervone thanks for replying! And yes, you are right. I had a crash now in the onNewToken method as you describe. Where you able to solve it using a different approach?

@francescocervone
Copy link
Author

No, except some horrible workaround like surrounding EntryPoints.get with a try catch or checking some static Boolean for tests only...

@bcorso
Copy link

bcorso commented Dec 7, 2020

FWIW, we're considering loosening these restrictions a bit.

Basically, we want to allow you to use EntryPoints.get() before your test instance is created. While this would lead to a runtime exception if your entry point depends on an @BindValue, the error message should be pretty clear (e.g. Dagger will tell you that generated @Provides for the particular @BindValue field returned null when it should have been non-null).

copybara-service bot pushed a commit that referenced this issue Feb 24, 2021
…before the test instance is instantiated.

See #2016

RELNOTES=Add EarlyTestEntryPoints to allow entry points to be called in tests before the test instance is instantiated.
PiperOrigin-RevId: 356184068
copybara-service bot pushed a commit that referenced this issue Feb 24, 2021
…before the test instance is instantiated.

See #2016

RELNOTES=Add EarlyTestEntryPoints to allow entry points to be called in tests before the test instance is instantiated.
PiperOrigin-RevId: 356184068
copybara-service bot pushed a commit that referenced this issue Feb 24, 2021
…before the test instance is instantiated.

See #2016

RELNOTES=Add EarlyTestEntryPoints to allow entry points to be called in tests before the test instance is instantiated.
PiperOrigin-RevId: 356184068
copybara-service bot pushed a commit that referenced this issue Feb 24, 2021
…before the test instance is instantiated.

See #2016

RELNOTES=Add EarlyTestEntryPoints to allow entry points to be called in tests before the test instance is instantiated.
PiperOrigin-RevId: 356184068
copybara-service bot pushed a commit that referenced this issue Feb 24, 2021
…before the test instance is instantiated.

See #2016

RELNOTES=Add EarlyTestEntryPoints to allow entry points to be called in tests before the test instance is instantiated.
PiperOrigin-RevId: 359350904
@ReginFell
Copy link

ReginFell commented Mar 2, 2021

We use app-startup library for initialization of different stuff like WorkManager and we use this

@EntryPoint
@InstallIn(SingletonComponent::class)
interface InitializerEntryPoint {

    fun inject(initializer: WorkManagerInitializer)

    companion object {
        fun resolve(context: Context): InitializerEntryPoint {
            val appContext = context.applicationContext ?: throw IllegalStateException()
            return EntryPointAccessors.fromApplication(
                appContext,
                InitializerEntryPoint::class.java
            )
        }
    }
}

But obviously it does not work for tests. What we tried is to use EarlyEntryPoint

@EarlyEntryPoint
@InstallIn(SingletonComponent::class)
interface InitializerEntryPoint {

    fun inject(initializer: WorkManagerInitializer)

    companion object {
        fun resolve(context: Context): InitializerEntryPoint {
            val appContext = context.applicationContext ?: throw IllegalStateException()
            return EarlyEntryPoints.get(
                appContext,
                InitializerEntryPoint::class.java
            )
        }
    }
}

And now EarlyEntryPoints.get throws No instrumentation registered! Must run under a registering instrumentation

Any ideas how it could be fixed?

@bcorso
Copy link

bcorso commented Mar 2, 2021

Do you have a full stacktrace?

Are you running this as a local (e.g. robolectric) or instrumentation (e.g. espresso) test?

Also, since this error is not directly caused by Hilt (i.e. we don't throw it) it may be worth searching/asking on stackoverflow for more generally what causes this problem. For example, from a quick search, it seems like that error is sometimes caused when mixing support and androidx dependencies, so that might be worth checking.

@francescocervone
Copy link
Author

@bcorso the original issue is fixed by using a combination of @EarlyEntryPoint and the solution proposed here -> #2016 (comment)


@ReginFell usually the No instrumentation registered error appears when you are trying to test code dependent on the android framework on the JVM but you forgot to annotate the test class with

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class TestClass {  }

Of course you need Robolectric in order to make the test pass on the JVM.
It should be unrelated to @EarlyEntryPoint annotation.

@bcorso
Copy link

bcorso commented Mar 3, 2021

@francescocervone, thanks for the update! I'll go ahead and close this now.

@ampeixoto
Copy link

We use app-startup library for initialization of different stuff like WorkManager and we use this

@EntryPoint
@InstallIn(SingletonComponent::class)
interface InitializerEntryPoint {

    fun inject(initializer: WorkManagerInitializer)

    companion object {
        fun resolve(context: Context): InitializerEntryPoint {
            val appContext = context.applicationContext ?: throw IllegalStateException()
            return EntryPointAccessors.fromApplication(
                appContext,
                InitializerEntryPoint::class.java
            )
        }
    }
}

But obviously it does not work for tests. What we tried is to use EarlyEntryPoint

@EarlyEntryPoint
@InstallIn(SingletonComponent::class)
interface InitializerEntryPoint {

    fun inject(initializer: WorkManagerInitializer)

    companion object {
        fun resolve(context: Context): InitializerEntryPoint {
            val appContext = context.applicationContext ?: throw IllegalStateException()
            return EarlyEntryPoints.get(
                appContext,
                InitializerEntryPoint::class.java
            )
        }
    }
}

And now EarlyEntryPoints.get throws No instrumentation registered! Must run under a registering instrumentation

Any ideas how it could be fixed?

@ReginFell were you able to solve this issue?

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

6 participants