First Class Dependency Injection with Dagger in Cucumber

A few weeks ago, someone on my team came to ask me a question about how we wrote our functional tests: ‘Why do we not use Dagger for our dependency injection in our cucumber tests when we use it universally for the application code?’. I gave what felt like a disappointing answer:

“Dagger has no first class integration with cucumber.”

Feeling this answer wasn’t completely satisfying, I went searching to see if someone had attempted to solve this problem. Jamie Tanna wrote an excellent post ‘Using Dagger for Dependency Injection with Cucumber Tests’ (go and read after the fact) that details integrating the DI framework into acceptance tests manually. The summary is that you manually create the component and pull dependencies out of it in your step definitions, like this:

public class StepDefinitions {

    private final Belly belly;
    public StepDefinitions() {
        this.belly = DaggerConfig.create().belly();
     }
}

Again, I felt a sense of lacklustre. This solution was missing that automagical dependency resolution and per-scenario state management that both the Spring and the PicoContainer DI integrations provide. I decided that with some time on my hands, I would solve this problem and this post details how I did it.


What is Dagger?

If you’re already sold on Dagger, skip ahead. If not, as JME famously said: If you don’t know, get to know!

Dagger is a compile-time dependency injection framework for Java and Android. Unlike Spring or Guice, which wire your object graph at runtime via reflection, Dagger runs as an annotation processor during compilation and generates plain Java source code to do the wiring. By the time your tests run there is no reflection, no magic, and no surprises - just generated Dagger* classes that any IDE can navigate.

The key building blocks:

That’s enough Dagger to follow the rest of this post. The official docs are genuinely good if you want to go deeper.


What first-class Cucumber DI actually means

Cucumber has a pluggable DI contract: implement ObjectFactory, register it via META-INF/services, and Cucumber will use your framework to resolve step definitions. That’s the whole interface.

In my opinion for a DI framework to be truly first-class here, it needs two things:

  1. Automatic glue code dependency wiring - the glue code (step definitions and hooks) should be resolved from the application’s dependency graph without any manual wiring or boilerplate per project.
  2. Per-scenario state management - some objects need to be fresh for each scenario. Without this, you’re back to mutable static state and the whole thing unravels.

Automatic glue code dependency wiring - using an annotation processor

The obvious way to wire step definitions into a Dagger component at runtime would be classpath scanning: find all classes annotated with Cucumber’s step annotations, look them up in the component and return them. Bob’s your uncle!

The problem is that this is exactly the kind of runtime reflection that Dagger was designed to eliminate. Dagger’s whole value proposition is that your dependency graph is validated and resolved at compile time. Bolting a reflective classpath scanner onto the front of it would undermine that entirely.

The natural answer I came up with was to do the same thing Dagger does and leverage an annotation processor. At compile time, the processor can inspect your component, discover your step definition classes, and generate a resolver that dispatches getInstance calls via a plain if (type == X.class) chain. By the time your tests run, there is no scanning, no reflectio just generated code.

This is why cucumber-dagger ships as both a runtime library and an annotation processor. The processor does the structural work at build time so the runtime doesn’t have to.

For a deeper look at the generated code and how the processor pipeline works, see the architecture docs.


Per-scenario state management - Dagger subcomponents save the day!

In a well-structured acceptance test suite, some objects should live for the duration of the entire test run - a database connection pool, an HTTP client, a price list loaded from a fixture file. Others should be fresh for every scenario - a basket, a user session, any state that accumulates during a scenario and must not bleed into the next.

PicoContainer handles this by recreating the entire container between scenarios. Simple, if blunt. Every object is scenario-scoped by default because everything is thrown away and rebuilt. The price you pay is that you lose shared singleton state, which forces people into increasingly creative uses of static fields.

Dagger’s mental model is different. A @Component is a sealed, immutable object graph. You create it once, you use it, you don’t modify it. There is no built-in concept of “create this binding fresh for each test scenario.”

The naive approaches are all bad:

What we actually need is a way to say: this part of the graph is per-run, and this part is per-scenario, with a clear boundary between them. After doing some digging I found Dagger has exactly that - it’s called a subcomponent.

A @Subcomponent is a child component that:

  1. Has access to all bindings from its parent component.
  2. Can declare its own scope (distinct from the parent’s scope).
  3. Is created by the parent - the parent controls the child’s lifetime.
  4. Is discarded when you stop holding a reference to it.

The parent component maps onto the test run. The subcomponent maps onto a single scenario. You create it at the start of each scenario and drop the reference at the end. The parent, and all its singletons, lives on unaffected.

The scope system enforces the lifetime boundary. The dagger-cucumber library provides a custom @ScenarioScope scope to provide a binding that should be recreated between scenarios.

Everything you can do with @ScenarioScope — including passing state between step definition classes — is covered in the scenario scope docs.


How it all fits together

Annotate a component with @CucumberDaggerConfiguration and the annotation processor takes over. Here’s what it generates at compile time:

Generated fileWhat it isPurpose
GeneratedCucumber*@Component wrapperExtends your component and CucumberDaggerComponent; the Dagger entry point the runtime loads
GeneratedScopedComponent@SubcomponentHolds all @ScenarioScope bindings; created and discarded per scenario
GeneratedScopedModule@ModuleDeclares the subcomponent factory; included in the wrapper’s modules
GeneratedComponentResolverPlain classDispatches getInstance calls to the right provision method without reflection
META-INF/services/CucumberDaggerComponentService fileTells the runtime which DaggerGeneratedCucumber* class to load

Curious about what the generated code actually looks like? The architecture docs walk through each file in detail.

At the start of each scenario a fresh GeneratedScopedComponent is created from the root component and dropped at the end:

Sequence diagram showing the per-scenario lifecycle

Look how far we’ve come

To appreciate what first-class support actually buys you, it’s worth looking at what the manual approach requires. Without a dedicated library, each step definition has to construct the Dagger component itself:

// The manual approach — each step def wires itself up
public class StepDefinitions {
    private final Belly belly;

    public StepDefinitions() {
        this.belly = DaggerConfig.create().belly();
    }
}

And the component needs an explicit provision method for every type it vends:

@Singleton
@Component(modules = ConfigModule.class)
public interface Config {
    Belly belly(); // manually listed for every step def
}

Every step definition constructs its own component. There’s no shared graph, no singleton state across step defs, and no path to scenario scoping whatsoever. It works for a single step def class; it falls apart as soon as you have more than one.

Here’s the same thing with cucumber-dagger. Add the dependencies:

// build.gradle.kts
dependencies {
    testImplementation(platform("dev.joss:cucumber-dagger-bom:<version>"))
    testImplementation("dev.joss:cucumber-dagger")

    testAnnotationProcessor("dev.joss:cucumber-dagger-processor")
    testAnnotationProcessor("com.google.dagger:dagger-compiler:<dagger-version>")
}

Declare your root component. @CucumberDaggerConfiguration is the only addition — everything else is standard Dagger:

@CucumberDaggerConfiguration
@Singleton
@Component(modules = {AppModule.class, ScenarioModule.class})
public interface IntegrationTestConfig { }

Singleton bindings go in AppModule, scenario-scoped bindings in ScenarioModule:

@Module
public abstract class AppModule {

    @Provides
    @Singleton
    static HttpClient provideHttpClient() {
        return HttpClient.newHttpClient(); // one instance for the whole test run
    }
}

@Module
public class ScenarioModule {

    @Provides
    @ScenarioScope
    static Basket provideBasket(HttpClient client) {
        return new Basket(client); // fresh instance per scenario
    }
}

Step definitions just declare what they need:

public class CheckoutSteps {

    private final Basket basket;

    @Inject
    public CheckoutSteps(Basket basket) {
        this.basket = basket;
    }

    @Given("a customer has {int} items in their basket")
    public void customerHasItems(int count) {
        basket.addItems(count);
    }
}

No ObjectFactory. No provision methods. No component construction in step defs. The Basket is recreated for every scenario; the HttpClient is not. Run ./gradlew test and Dagger handles the rest.

Ready to set this up in your own project? The getting started guide covers dependencies, configuration, and the full feature set.


Conclusion

The core insight is that the problem isn’t “Dagger doesn’t support test DI” - it’s that nobody had written the annotation processor to make it seamless. The wiring was always possible; the ergonomics just weren’t there.

cucumber-dagger adds:

The full setup guide, configuration reference, and troubleshooting docs are in the README.