Skip to content

Commit

Permalink
Hilt: allow custom injection support for tests.
Browse files Browse the repository at this point in the history
One of the key decisions with Hilt is bytecode rewriting. It helps simplify the developer experience, but makes things more complicated for testing. As a result Hilt provides additional testing framework that helps mitigate these concerns and allows for great flexibility when it comes to mocking and replacing dependencies for testing.

Still, hilt has non-trivial compilation costs. And as the codebase growth, we've observed that the cost for test complication growth even more so than for production code. As a result there is an exploration to avoid using hilt for simpler cases where the value of DI graph in tests is very small, but the additional costs to compile are great.

This diff introduces a few small touches to Hilt codegen to allow for a runtime test DI (like a simpler version of Guice) to overtake the injection.

Specifically, this diff introduces `TestInjectInterceptor` class with a single empty static method `injectForTesting()`. The codegen for Activities, Fragments, Views, Services, and Broadcasts  is adjusted to have the next code:

```
protected void inject() {
    if (!injected) {
      injected = true;
      if (TestInjectInterceptor.injectForTesting(this)) {
        return;
      }
      // rest of Hilt injection code.
  }
```

For production or tests running under Hilt the additional code does nothing. And for production this code should be eliminated by R8. But for cases where testing framework is able to intercept a call to `TestInjectInterceptor.injectForTesting()` (like Robolectric shadow), the injection can be overtake in a consistent manner for all types of supported android entry points.
  • Loading branch information
inazaruk committed Dec 1, 2023
1 parent 344e135 commit dd64ca2
Show file tree
Hide file tree
Showing 8 changed files with 41 additions and 3 deletions.
3 changes: 2 additions & 1 deletion java/dagger/hilt/EntryPoint.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package dagger.hilt;

import static java.lang.annotation.RetentionPolicy.CLASS;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
Expand Down Expand Up @@ -44,7 +45,7 @@
*
* @see <a href="https://dagger.dev/hilt/entry-points">Entry points</a>
*/
@Retention(CLASS)
@Retention(RUNTIME)
@Target(ElementType.TYPE)
@GeneratesRootInput
public @interface EntryPoint {}
1 change: 1 addition & 0 deletions java/dagger/hilt/android/internal/managers/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ android_library(
"SavedStateHandleModule.java",
"ServiceComponentManager.java",
"ViewComponentManager.java",
"TestInjectInterceptor.java",
],
exports = [":saved_state_handle_holder"],
deps = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dagger.hilt.android.internal.managers;

/**
* This class does nothing in production or in tests when running under Hilt testing framework. However, the calls
* to TestInjectInterceptor.injectForTesting() are done in a few strategic places just before Hilt does the injection
* into Android Components.
*
* As a result this class enables non-Hilt based frameworks to take over the injection process.
*/
public class TestInjectInterceptor {
/**
* This method always returns false by default. However, if this method is intercepted during testing
* by frameworks like Robolectric, the intercepting code can take over the injection process and
* instruct Hilt to skip doing anything extra for this instance.
*
* Return false if no custom injection was done and Hilt should continue as normal. Return true
* if the testing framework has takes over the injection process and Hilt should skip any extra
* work.
*/
public static boolean injectForTesting(Object injectTo) {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ && ancestorExtendsGeneratedHiltClass(metadata)) {
methodSpecBuilder
.beginControlFlow("if (!injected)")
.addStatement("injected = true")
.beginControlFlow("if ($T.injectForTesting(this))", ClassNames.TEST_INJECT_INTERCEPTOR)
.addStatement("return")
.endControlFlow()
.addStatement(
"(($T) $L).$L($L)",
metadata.injectorClassName(),
Expand All @@ -395,6 +398,10 @@ && ancestorExtendsGeneratedHiltClass(metadata)) {
.beginControlFlow("if (!injected)")
.beginControlFlow("synchronized (injectedLock)")
.beginControlFlow("if (!injected)")
.beginControlFlow("if ($T.injectForTesting(this))", ClassNames.TEST_INJECT_INTERCEPTOR)
.addStatement("injected = true")
.addStatement("return")
.endControlFlow()
.addStatement(
"(($T) $T.generatedComponent(context)).$L($L)",
metadata.injectorClassName(),
Expand Down
3 changes: 2 additions & 1 deletion java/dagger/hilt/android/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Keep for the reflective cast done in EntryPoints.
# See b/183070411#comment4 for more info.
-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EarlyEntryPoint class *
-keep,allowobfuscation,allowshrinking @dagger.hilt.android.EarlyEntryPoint class *
-assumenosideeffects class dagger.hilt.android.internal.managers.TestInjectInterceptor { *; }
2 changes: 1 addition & 1 deletion java/dagger/hilt/android/qualifiers/ActivityContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@

/** Annotation for a {@code Context} that corresponds to the activity. */
@Qualifier
@Retention(RetentionPolicy.CLASS)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
public @interface ActivityContext {}
3 changes: 3 additions & 0 deletions java/dagger/hilt/android/qualifiers/ApplicationContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
package dagger.hilt.android.qualifiers;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.inject.Qualifier;

/** Annotation for an Application Context dependency. */
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
public @interface ApplicationContext {}
2 changes: 2 additions & 0 deletions java/dagger/hilt/processor/internal/ClassNames.java
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ public final class ClassNames {
get("dagger.hilt.android.internal.testing", "InternalTestRoot");
public static final ClassName TEST_INJECTOR =
get("dagger.hilt.android.internal.testing", "TestInjector");

public static final ClassName TEST_INJECT_INTERCEPTOR = get("dagger.hilt.android.internal.managers", "TestInjectInterceptor");
public static final ClassName TEST_APPLICATION_COMPONENT_MANAGER =
get("dagger.hilt.android.internal.testing", "TestApplicationComponentManager");
public static final ClassName TEST_APPLICATION_COMPONENT_MANAGER_HOLDER =
Expand Down

0 comments on commit dd64ca2

Please sign in to comment.