Search code examples
javaunit-testingjunitdependency-injectionguice

Creating a custom @TestScoped Guice scope for the duration of a unit test


I would like to scope objects so that they exist as singletons for the duration of a unit test. The pattern would follow the @RequestScoped and @SessionScoped already implemented in Google Guice, by would scope around the @Before and the @After of a junit test:

public class MyUnitTest {
    @TestScoped static class MyTestScopedClass { }

    @Before public void enterTestScope() {
        // something here creates the TestScope
    }

    @After public void exitTestScope() {
        // destroy the TestScope
    }

    @Test public void MyTest() {
        // MyTest instantiates object using injection, some are @Singletons
        // and will remain for other tests, but those that are @TestScoped
        // will be created once for this test and will be automatically 
        // destroyed at the end of this test
    }
} 

Is this possible with Google Guice ?


Solution

  • Answering my question

    1. Create the TestScoped annotation:
    import com.google.inject.ScopeAnnotation;
    
    @Target({ TYPE, METHOD })
    @Retention(RUNTIME)
    @ScopeAnnotation
    public @interface TestScoped
    {}
    
    
    1. Create a TestScope class that will manage the lifecycle of the instances in TestScoped:
    package com.see.os.configuration.context.unittests;
    
    public class TestScope extends SimpleScope
    {}
    

    Here, the SimpleScope is the one from the Guice documentation. Including for completeness:

    import static com.google.common.base.Preconditions.checkState;
    import com.google.common.collect.Maps;
    import com.google.inject.Key;
    import com.google.inject.OutOfScopeException;
    import com.google.inject.Provider;
    import com.google.inject.Scope;
    import java.util.Map;
    
    /**
     * Scopes a single execution of a block of code. Apply this scope with a
     * try/finally block: <pre><code>
     *
     *   scope.enter();
     *   try {
     *     // explicitly seed some seed objects...
     *     scope.seed(Key.get(SomeObject.class), someObject);
     *     // create and access scoped objects
     *   } finally {
     *     scope.exit();
     *   }
     * </code></pre>
     *
     * The scope can be initialized with one or more seed values by calling
     * <code>seed(key, value)</code> before the injector will be called upon to
     * provide for this key. A typical use is for a servlet filter to enter/exit the
     * scope, representing a Request Scope, and seed HttpServletRequest and
     * HttpServletResponse.  For each key inserted with seed(), you must include a
     * corresponding binding:
    
     *  <pre><code>
     *   bind(key)
     *       .toProvider(SimpleScope.&lt;KeyClass&gt;seededKeyProvider())
     *       .in(ScopeAnnotation.class);
     * </code></pre>
     *
     * @author Jesse Wilson
     * @author Fedor Karpelevitch
     */
    public class SimpleScope implements Scope {
    
      private static final Provider<Object> SEEDED_KEY_PROVIDER =
          new Provider<Object>() {
            public Object get() {
              throw new IllegalStateException("If you got here then it means that" +
                  " your code asked for scoped object which should have been" +
                  " explicitly seeded in this scope by calling" +
                  " SimpleScope.seed(), but was not.");
            }
          };
      private final ThreadLocal<Map<Key<?>, Object>> values
          = new ThreadLocal<Map<Key<?>, Object>>();
    
      public void enter() {
        checkState(values.get() == null, "A scoping block is already in progress");
        values.set(Maps.<Key<?>, Object>newHashMap());
      }
    
      public void exit() {
        checkState(values.get() != null, "No scoping block in progress");
        values.remove();
      }
    
      public <T> void seed(Key<T> key, T value) {
        Map<Key<?>, Object> scopedObjects = getScopedObjectMap(key);
        checkState(!scopedObjects.containsKey(key), "A value for the key %s was " +
            "already seeded in this scope. Old value: %s New value: %s", key,
            scopedObjects.get(key), value);
        scopedObjects.put(key, value);
      }
    
      public <T> void seed(Class<T> clazz, T value) {
        seed(Key.get(clazz), value);
      }
    
      public <T> Provider<T> scope(final Key<T> key, final Provider<T> unscoped) {
        return new Provider<T>() {
          public T get() {
            Map<Key<?>, Object> scopedObjects = getScopedObjectMap(key);
    
            @SuppressWarnings("unchecked")
            T current = (T) scopedObjects.get(key);
            if (current == null && !scopedObjects.containsKey(key)) {
              current = unscoped.get();
    
              // don't remember proxies; these exist only to serve circular dependencies
              if (Scopes.isCircularProxy(current)) {
                return current;
              }
    
              scopedObjects.put(key, current);
            }
            return current;
          }
        };
      }
    
      private <T> Map<Key<?>, Object> getScopedObjectMap(Key<T> key) {
        Map<Key<?>, Object> scopedObjects = values.get();
        if (scopedObjects == null) {
          throw new OutOfScopeException("Cannot access " + key
              + " outside of a scoping block");
        }
        return scopedObjects;
      }
    
      /**
       * Returns a provider that always throws exception complaining that the object
       * in question must be seeded before it can be injected.
       *
       * @return typed provider
       */
      @SuppressWarnings({"unchecked"})
      public static <T> Provider<T> seededKeyProvider() {
        return (Provider<T>) SEEDED_KEY_PROVIDER;
      }
    }
    
    1. Annotate your class with @TestScoped
    @TestScoped
    class ScopedObj
    {}
    
    1. In your injection configuration, associate the TestScoped annotation to an instance of TestScope:
    class TestScopeTest
    {
        private final Injector m_injector = Guice.createInjector(new AbstractModule()  {
            @Override protected void configure()  {
                TestScope testScope = new TestScope();
                bindScope(TestScoped.class, testScope);
                bind(TestScope.class).toInstance(testScope);
            }
        });
    
    
    1. In the unit test, you can not "enter" and "exit" the scope when the test starts and finishes.
        @BeforeEach
        void onBeginTest() {
            m_injector.getInstance(TestScope.class).enter();
        }
    
        @AfterEach
        void onFinishTest() {
            m_injector.getInstance(TestScope.class).exit();
        }
    
    1. Your test can now expect TestScoped objects to persists as singletons for the duration of the test and be reset between each tests:
        static ScopedObj s_previousScopedObj;
    
        @RepeatedTest(2)
        void givenTest_whenGetTestScoped_ensureIsScopedToTest()  {
            // when
            ScopedObj obj = m_injector.getInstance(ScopedObj.class);
            ScopedObj objAgain = m_injector.getInstance(ScopedObj.class);
    
            // ensure
            Assertions.assertSame(obj, objAgain);
            Assertions.assertNotSame(s_previousScopedObj, obj);
            s_previousScopedObj = objAgain;
        }
    

    I provided a full sample here: https://gist.github.com/gaspardpetit/61e95523a6a7672dd65c97a913cce33f