The Uneasy Relationship Between Dependency Injection and Integration Testing

Doing white-box integration testing, by definition, does not mean to take the whole application and hit it from its public endpoints. You are expected to take some nicely testable subset of services, compose them in a manner similar to their usage in the whole application, and then verify the soundness of the overall logic.

From distance, this seems like a simple operation.

Technicalities introduce themselves, or, a pit of despair

A naïve way to compose services for integration testing is by… composing them:

var mockDb = new MockDatabase()
    .With(new Item { Id = 123, ... })
    .With(new Customer { Id = 456, ... });
var calculator = new PriceCalculator(
    new ItemRepository(mockDb),
    new CustomerLoyaltyProcessor(
        new CustomerRepository(...),
        ...),
    ...);

var price = calculator.GetPrice(itemId: 123, customerId: 456);

Assert.AreEqual(4.5M, price);

Mark Seemann wrote once about “pits of success”, designs that are guiding future work around them to be in the right direction. What we created right now is the exact opposite, a pit of despair, if you will. It’s a collision between SOLID and testing that will force programmers to hate testing when applying sound SOLID principles, and to hate SOLID when diligently testing things that should be tested.

Imagine what happens when you add a dependency. You need to register it into your composition root – that’s a productive thing to do because you need to make some decisions here about its lifestyle anyway. Then you run compilation and notice you broke hundred integration tests, because even though you can think that constructors are implementation detail, your testing suite does not think about it that way now.

You are no longer compelled to make new services when new responsibilities arise: you are much more comfortable with extending things that are already in place, because you don’t want to fix all the tests all the time. You broke SOLID, and it’s more and more broken with more and more tests you are introducing.

Now, let’s say you want to add a new white-box integration test. You have to fulfil dependencies of the service under the test by hand so to speak. It won’t be an interesting and productive work, because you are just translating dependencies that are already described in you production composition. After all, it’s the production situation you are eager to mimic.

You are probably going to copy the wall of code from some other test and then make two changes there. These two changes are only two things that make sense to write down because they are the pieces specific to the test context. The two things you changed are going to be hidden for every next pair of eyes looking into the testing code, as they are in the copy-pasted wall of code.

You not only don’t want to write new tests now, you don’t want to fix or bother with tests broken by changes you did in SUT, because they are not comprehensible. When reading integration tests, you want to know how they differ from the production code, not how exactly are they constructed. You broke integration testing, and the more you do SOLID, the more broken it is for you.

Maybe you are going to conclude that integration testing is a bad idea overall, that the original sin was to not assert every unit in complete isolation.

The obvious solution

The correct thing to do is just to take production composition root and ask it for the service you need.

Does it have some transitive dependencies that are going out of scope of your integration? Things like database calls or REST API calls? These are going to fail at test-time. There are at least three solutions:

Replace the service responsible for that particular integration. Typically, you are going to stub or mock at repository layer. I’m going to deal with technical details on how to do this a few paragraphs later.

Extend you integration. In the container age we are living in, everything is easy: do you need a database? Just add it to you testing docker-compose/helm chart. Do you need REST mock? Generate it from OpenApi spec and, again, run it in a container.

If the service is critical for verifying correctness of the system (e.g., a logging service), just let it be and observe whether the system reacts correctly for its failure. Just make sure to network-isolate your CI test runs to guarantee repeatability of the failure.

Replacing lifestyles

There’s a minor difficulty: there is no per-web-request lifestyle during testing. If you take ASP.NET composition root and try to use it to resolve a service outside of ASP.NET context, you will get an exception.

Luckily, Castle Windsor has a capability to tune service lifestyles after composition root construction. This is a great way to do it as we generally do not want to make changes to SUT just to facilitate our testing.

All you need to do is to create a IContributeComponentModelConstruction implementation to change per-web-request lifestyles to singletons:

public class PerWebRequestRemover : IContributeComponentModelConstruction
{
    public void ProcessModel(IKernel kernel, ComponentModel model)
    {
        if (model.LifestyleType == LifestyleType.PerWebRequest)
        {
            model.LifestyleType = LifestyleType.Singleton;
        }
    }
}

Then you can apply it to your container by simply calling container.Kernel.ComponentModelBuilder.AddContributor(new PerWebRequestRemover()).

Replacing services

What about replacing service implementations in already constructed composition root? Again, Castle Windsor has our back: you can take an already constructed container and override some already registered ISomeInterface by calling Register with Component.For<ISomeInterface>().ImplementedBy<OverridingMockImplementation>().Named("any-unique-name").IsDefault().

Notice that if you take whole production composition root for every test and apply test-specific overrides only, you get the diff behavior we missed in the introduction to this blogpost. Your tests no longer describe production, they describe how they differ from production.

Conclusion

We were trying hard to teach ourselves to not touch composition root from outside in our everyday work as any time we do that in SUT, we are creating a service locator.

Unfortunately, creative work with circumstances of our composition is exactly what we need to enable key white-box integration testing scenarios.

» Subscribe to RSS
» Write me an e-mail