Testing Guice, Writing It in Kotlin, and Not Shooting Yourself in the Foot
Series: Guice for JVM Engineers · II (Advanced & Real-World) — Part 8 of 8
Sixteen posts ago we started with a complaint: the new keyword metastasizes, and every caller ends up knowing how to build the entire object graph beneath the thing it actually wants. Guice’s answer was to make wiring someone else’s job while keeping it explicit code you can read. Now, at the end of two series, here’s the punchline that justifies the whole exercise — the biggest payoff of dependency injection isn’t startup wiring at all. It’s that your tests stop fighting you. This finale is about cashing that in: how to test Guice code, how to write Guice in Kotlin without drowning in ::class.java, what the Jakarta move means for your upgrade, and the handful of habits that keep a Guice codebase honest as it grows.
The best Guice test uses no Guice at all
Start here, because it’s the part people forget the moment they learn the framework. A class that takes its collaborators through the constructor doesn’t need an injector to be built — you can just new it with fakes. That’s the entire point of constructor injection. The test reads as plain object construction, with zero container, zero module, zero annotations:
class GreetingService @Inject constructor(
private val repository: GreetingRepository,
private val clock: Clock,
) {
fun greet(name: String) =
"${repository.greetingFor(name)} at ${clock.instant()}"
}
// The test. No Guice. Just a constructor and two fakes.
@Test
fun `greets using the repository`() {
val fakeRepo = GreetingRepository { name -> "Hello, $name" }
val fixedClock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC)
val service = GreetingService(fakeRepo, fixedClock)
assertEquals("Hello, world at 1970-01-01T00:00:00Z", service.greet("world"))
}class GreetingService {
private final GreetingRepository repository;
private final Clock clock;
@Inject GreetingService(GreetingRepository repository, Clock clock) {
this.repository = repository;
this.clock = clock;
}
String greet(String name) {
return repository.greetingFor(name) + " at " + clock.instant();
}
}
// The test. No Guice. Just a constructor and two fakes.
@Test
void greetsUsingTheRepository() {
GreetingRepository fakeRepo = name -> "Hello, " + name;
Clock fixedClock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
GreetingService service = new GreetingService(fakeRepo, fixedClock);
assertEquals("Hello, world at 1970-01-01T00:00:00Z", service.greet("world"));
}Notice the @Inject annotation is still there and the test ignores it completely. Guice reads that annotation at runtime; a test calling the constructor directly doesn’t care. This is the design discipline paying a dividend: because the class declares its dependencies as parameters instead of reaching out for them, substituting a fake is just passing a different argument. No mocking framework required for the simple cases — a fixed Clock, a lambda implementing the interface, a small hand-written fake.
If you find yourself reaching for an injector to unit-test a single class, stop and ask why that class can’t be constructed plainly. The usual answer is that it’s grabbing dependencies through field injection or a static lookup — which is exactly the anti-pattern we’ll get to. A well-shaped Guice class is trivially testable without Guice, and most of your tests should stay there.
Integration tests: swap whole modules with Modules.override
Unit tests build one object. Integration tests build a slice of the real graph — several collaborators wired together the way production wires them — but with the dangerous edges (the real database, the live payment gateway, the wall clock) replaced by fakes. You don’t want to re-declare the whole module for the test; you want to take the production module and surgically override the few bindings that touch the outside world.
Modules.override(production).with(fakes) does exactly that. It returns a module that is the production module, except any binding also declared in the override module wins:
import com.google.inject.Guice
import com.google.inject.util.Modules
class FakeModule : AbstractModule() {
override fun configure() {
bind(GreetingRepository::class.java).to(InMemoryGreetingRepository::class.java)
bind(Clock::class.java).toInstance(Clock.fixed(Instant.EPOCH, ZoneOffset.UTC))
}
}
@Test
fun `service uses the in-memory repo in tests`() {
val injector = Guice.createInjector(
Modules.override(ProdModule()).with(FakeModule()),
)
val service = injector.getInstance(GreetingService::class.java)
assertEquals("Hi, world at 1970-01-01T00:00:00Z", service.greet("world"))
}import com.google.inject.Guice;
import com.google.inject.util.Modules;
class FakeModule extends AbstractModule {
@Override protected void configure() {
bind(GreetingRepository.class).to(InMemoryGreetingRepository.class);
bind(Clock.class).toInstance(Clock.fixed(Instant.EPOCH, ZoneOffset.UTC));
}
}
@Test
void serviceUsesTheInMemoryRepoInTests() {
Injector injector = Guice.createInjector(
Modules.override(new ProdModule()).with(new FakeModule()));
GreetingService service = injector.getInstance(GreetingService.class);
assertEquals("Hi, world at 1970-01-01T00:00:00Z", service.greet("world"));
}The mental model: override produces a graph where the override module’s bindings shadow the base module’s. Everything you don’t override stays exactly as production built it, so the wiring under test is the real wiring — that’s the value. The base module here is ProdModule(); in a larger app you’d pass the whole production module list (Modules.override(productionModules).with(testModules)).
One honest caveat: Modules.override is a slightly blunt instrument, and the Guice docs themselves treat it as a tool to be used sparingly. It silently lets a test replace a binding, which is great until an override quietly masks a binding you didn’t mean to touch, or you refactor the production binding and the override goes stale without telling you. Reach for it in tests freely; think twice before using it to layer production modules.
BoundFieldModule: bind test doubles by declaring fields
The third tool is the most ergonomic for test classes that need a few mocks wired into the graph. Instead of writing a FakeModule by hand, you annotate fields on the test itself with @Bind, and BoundFieldModule.of(this) turns each annotated field into a binding to that field’s value. It lives in the com.google.inject.testing.fieldbinder package, shipped in the com.google.inject.extensions:guice-testlib artifact (a separate dependency from core Guice).
import com.google.inject.Guice
import com.google.inject.testing.fieldbinder.Bind
import com.google.inject.testing.fieldbinder.BoundFieldModule
import org.mockito.Mockito.mock
class GreetingServiceTest {
// Each @Bind field becomes a binding to the field's value at injector-creation time.
@Bind val repository: GreetingRepository = mock(GreetingRepository::class.java)
@Bind val clock: Clock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC)
@Inject lateinit var service: GreetingService
@BeforeEach
fun setUp() {
// injectMembers fills in @Inject fields on `this`, e.g. `service`.
Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this)
}
@Test
fun greets() {
whenever(repository.greetingFor("world")).thenReturn("Hello, world")
assertEquals("Hello, world at 1970-01-01T00:00:00Z", service.greet("world"))
}
}import com.google.inject.Guice;
import com.google.inject.testing.fieldbinder.Bind;
import com.google.inject.testing.fieldbinder.BoundFieldModule;
import static org.mockito.Mockito.mock;
class GreetingServiceTest {
// Each @Bind field becomes a binding to the field's value at injector-creation time.
@Bind private GreetingRepository repository = mock(GreetingRepository.class);
@Bind private Clock clock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
@Inject private GreetingService service;
@BeforeEach
void setUp() {
// injectMembers fills in @Inject fields on `this`, e.g. `service`.
Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this);
}
@Test
void greets() {
when(repository.greetingFor("world")).thenReturn("Hello, world");
assertEquals("Hello, world at 1970-01-01T00:00:00Z", service.greet("world"));
}
}BoundFieldModule walks the test object (and its superclasses), and for every @Bind field it adds a binding from that field’s type to the field’s current value. A few rules from the source that are worth knowing:
@Bind(to = SuperType::class.java)binds the value to a declared supertype instead of the field’s actual type — handy when the field is concrete but the graph wants the interface.- A qualifier or binding annotation on the field carries over:
@Bind @Fast val car: Car = ...binds@Fast Car. More than one binding annotation on a field is an error. - A
Provider<T>field binds as a provider ofTrather than as the provider object itself. @Bind(lazy = true)defers reading the field until injection time, so you can reassign the field mid-test and have the new value flow through. Without it, the value is read once at injector creation.@Bindand@Injecton the same field is illegal —@Bindsupplies a value into the graph;@Injectpulls one out.
This is the pattern most large Guice test suites converge on, because the test class reads as a manifest: here are my doubles, here’s the thing under test, wire it up. All three approaches coexist: plain construction for unit tests, BoundFieldModule for wiring a test class’s doubles, and Modules.override for swapping module-level bindings in a bigger integration slice.
Kotlin idioms: taming ::class.java
Throughout this series the Kotlin tab carried a tax: Guice’s API is built around java.lang.Class and TypeLiteral, so Kotlin has to write bind(Foo::class.java).to(Bar::class.java) where Java gets the tidier bind(Foo.class).to(Bar.class). It’s noise, and after the hundredth ::class.java you start resenting it.
Kotlin’s inline + reified is the cure. A reified type parameter survives erasure inside an inline function, so you can recover the Class object from the type alone and hide the ::class.java behind a helper:
import com.google.inject.AbstractModule
import com.google.inject.binder.AnnotatedBindingBuilder
import com.google.inject.binder.LinkedBindingBuilder
inline fun <reified T> AbstractModule.bind(): AnnotatedBindingBuilder<T> =
binder().bind(T::class.java)
inline fun <reified T> LinkedBindingBuilder<in T>.to(): Unit =
to(T::class.java)
// Now modules read almost like the Java tab — no `::class.java` in sight:
class AppModule : AbstractModule() {
override fun configure() {
bind<GreetingRepository>().to<SqlGreetingRepository>()
bind<Clock>().toInstance(Clock.systemUTC())
}
}// Not applicable — this is the noise Java doesn't have. Java already writes:
class AppModule extends AbstractModule {
@Override protected void configure() {
bind(GreetingRepository.class).to(SqlGreetingRepository.class);
bind(Clock.class).toInstance(Clock.systemUTC());
}
}You don’t have to roll these by hand. Cash App’s Misk ships a KAbstractModule that bakes in exactly this bind<Foo>().to<Bar>() style (plus reified helpers for multibindings and TypeLiteral), and it’s the de facto pattern for Guice-in-Kotlin shops. If you’re writing more than a couple of modules in Kotlin, either adopt a KAbstractModule-style base class or drop a handful of reified extension functions into your codebase once — the readability win compounds.
A few more Kotlin-specific notes that bit people earlier in the series:
- Qualifier annotations declare cleanly in Kotlin, but mind the target. A binding annotation needs
@Retention(AnnotationRetention.RUNTIME), and on a constructor parameter you often need@param:Fast(or@field:) so the annotation lands where Guice looks. Declaring the annotation:@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class Fast. objectsingletons vs@Singleton. Kotlin’sobjectis a language-level singleton — one instance for the whole process. That’s not the same as Guice’s@Singleton, which is one instance per injector. If you want Guice to manage the lifecycle (and to be able to swap it in tests), bind a normal class as@Singleton; reach forobjectonly for genuinely stateless, framework-free helpers.lateinit varfor field/member injection works, but see the best-practices section — you mostly shouldn’t be doing field injection anyway.
The Jakarta move: javax.inject is gone in Guice 7
Guice 7 made one breaking change that dominates every upgrade conversation: it switched from javax.inject to jakarta.inject. The @Inject, @Qualifier, @Singleton, @Scope, and Provider you import from the standard annotations now come from the jakarta.inject package instead of javax.inject. Guice’s own annotations — com.google.inject.Inject, com.google.inject.Singleton, com.google.inject.Provider — are unchanged; it’s only the JSR-330 standard ones that moved namespaces.
What this means in practice:
- On Guice 6 or earlier, keep using
javax.inject. Guice 6 was the bridge release that understood both; Guice 7 droppedjavax. - Upgrading to 7 is mostly a find-and-replace of
javax.inject→jakarta.injectacross your imports — if nothing else in your stack is pinned tojavax. The catch is the rest of the ecosystem: a library that still hands Guicejavax.inject.Provider, or a servlet container on the old namespace, won’t line up. This is the same Jakarta EE namespace split that hit servlets and JPA, so it tends to come bundled with a broader platform upgrade rather than landing alone. - If you’re stuck — one dependency demands
javax, another demandsjakarta— you’re in classpath-mediation territory, and the honest answer is to untangle the dependency rather than bridge the namespaces. There’s no clean runtime shim that makes both annotations mean the same thing to Guice.
Everywhere in these two series the tabs imported jakarta.inject.Inject, because we’ve been on Guice 7 throughout. If you copied a sample into a Guice 6 project and the import didn’t resolve, that’s why.
Best practices, and the anti-patterns they replace
Guice gives you enough rope to build something elegant or to hang the whole team. The difference is a short list of habits. None of these are novel — they’re the consensus that fell out of Google running Guice at scale — but they’re worth stating plainly.
Prefer constructor injection. Avoid field and static injection. Constructor injection makes dependencies explicit, lets you mark fields final/val, and — the whole reason the testing section was short — lets you build the object in a test without Guice. Field injection (@Inject on a mutable field) hides dependencies, defeats immutability, and forces an injector into every test. Static injection (requestStaticInjection) is worse: it introduces global mutable state and ordering hazards. Treat both as escape hatches for framework integration you don’t control, not as tools you reach for.
Never inject the Injector itself. If a class takes an Injector and calls getInstance inside its methods, you’ve reinvented the service-locator pattern Guice exists to replace — dependencies are now invisible, resolved at call time, and untestable without a real injector. The rare legitimate need (you genuinely don’t know the type until runtime) is served by injecting a Provider<T> or a factory, not the whole injector.
Keep modules small and focused. A module should configure one cohesive area — a DatabaseModule, a PaymentsModule — and compose via install(). Small modules are easier to read, easier to override in tests, and easier to reason about when a binding goes missing. A 600-line AppModule that binds everything is where binding conflicts go to hide.
Fail fast: run in Stage.PRODUCTION and require explicit bindings. By default Guice.createInjector(modules) runs in Stage.DEVELOPMENT, which constructs singletons lazily. In Stage.PRODUCTION, Guice eagerly creates all singletons at startup, so a misconfigured graph blows up at boot with a CreationException rather than on the first request at 3 a.m. And binder.requireExplicitBindings() turns off just-in-time bindings, forcing every injectable type to be declared — no silently new-ing an unbound concrete class. Both trade a little convenience for a graph that’s verified up front.
import com.google.inject.Guice
import com.google.inject.Stage
class AppModule : AbstractModule() {
override fun configure() {
requireExplicitBindings() // no JIT bindings — every type must be bound
bind(GreetingRepository::class.java).to(SqlGreetingRepository::class.java)
}
}
fun main() {
// Eagerly builds singletons and validates the whole graph at startup.
val injector = Guice.createInjector(Stage.PRODUCTION, AppModule())
injector.getInstance(GreetingService::class.java).greet("world")
}import com.google.inject.Guice;
import com.google.inject.Stage;
class AppModule extends AbstractModule {
@Override protected void configure() {
requireExplicitBindings(); // no JIT bindings — every type must be bound
bind(GreetingRepository.class).to(SqlGreetingRepository.class);
}
}
// Eagerly builds singletons and validates the whole graph at startup.
Injector injector = Guice.createInjector(Stage.PRODUCTION, new AppModule());
injector.getInstance(GreetingService.class).greet("world");Prefer @Provides over clever reflection. When a binding needs construction logic, a @Provides method is plain, debuggable Java/Kotlin that your IDE understands. Resist the urge to build bindings through reflection, classpath scanning, or annotation-processing cleverness “to save boilerplate” — you’ll trade a few lines of explicit code for a graph nobody can read, which is the exact failure mode Guice was designed to avoid. The whole bet of Guice over scanning-based containers is legibility; don’t surrender it to save typing.
Gotchas
@Bindrequires theguice-testlibdependency. It’scom.google.inject.extensions:guice-testlib, separate from core Guice. If the import doesn’t resolve, you haven’t added it.BoundFieldModule.of(this)doesn’t injectthisby itself. It only registers the@Bindfields as bindings. To populate the test’s own@Injectfields you still need.injectMembers(this)on the resulting injector — the two calls are a pair.@Bindreads the field value at injector-creation time by default. Reassign a@Bindfield after creating the injector and the graph keeps the old value. Use@Bind(lazy = true)if the value must change during the test.- Kotlin
objectis not Guice@Singleton. One is a process-wide JVM singleton you can’t swap; the other is per-injector and swappable in tests. Don’t conflate them. Stage.PRODUCTIONsurfaces latent bugs you never saw in dev. A binding error that lazyDEVELOPMENTmode hid until first use will now fail at startup. That’s the feature — but expect the firstPRODUCTIONrun after a refactor to find things.Modules.overridecan silently mask a binding you didn’t intend. If you override a binding and later remove or rename the production one, the override lingers without an error. Keep override modules tiny and test-only.
What’s next
Nothing — and that’s the point. This is the end of the road.
Across Guice I we built the foundations: the module-and-injector loop, the ways to bind, Provider and @Provides, qualifiers, scopes, composing modules, and how to read the wreckage when it breaks. Across Guice II we went where real applications live: multibindings, generics with TypeLiteral, assisted injection, AOP interception, private modules, custom scopes, the servlet extension, and now testing and the habits that keep it all maintainable. If you’ve followed the thread, you can now do the thing the very first post promised — wire a real application’s object graph as explicit, readable code, and test it without the wiring getting in your way. That second half is the one most people miss, and it’s the half that pays the rent every single day you maintain the codebase.
The strong opinion that opened the series is the one to leave with: dependency injection is a design discipline — declare what you need, don’t construct it — and Guice is just the honest machine that completes it, with wiring you can open and read instead of magic you reverse-engineer from a stack trace. Master that, and frameworks built on top of Guice — Cash App’s Misk being the obvious one — stop looking like new things to learn and start looking like the same module-and-injector loop, scaled up.
You came here to delete some new keywords. You’re leaving able to architect and test the whole graph. Go wire something real.
Target keyword(s): guice testing, guice best practices.
Comments