So, I managed to solve this...
My solution is not without its drawbacks, but it does fundamentally achieve the safety I wanted.
Summary
Roughly speaking there are 2 aspects:
- Programmatically Test that every binding that the DI Kernel knows about can be resolved cleanly.
- Programmatically Test that every relevant Interface used in your codebase can be resolved cleanly.
Both take roughly the same path:
- Refactor your DI configuration code, so that the core portion of it that defines bindings for the meat of your app can be run in isolation from the rest of the Startup Code.
- At the start of your Test invoke the above DI config code, so that you have a replica of the kernel object that your site uses, whose bindings you can test
- perform some amount of Reflection, to generate a list of the relevant
Type
objects which the kernel should be able to provide.
- (optional) filter that list to ignore some classes and interfaces that you know your tests don't need concern themselves about (e.g. your code doesn't need to worry about whether the Kernel knows how to bootstrap itself, so it can ignore any Bindings it has in the namespace belonging to your DI framework.).
- Then loop over the Interface type objects you have left and ensure that
kernel.Get(interfaceType)
runs without an Exception for each one.
Read on for more of the Gory details...
Validating all defined Kernel Bindings
This is going to be specific to the DI framework in question, but for Ninject
it's pretty hairy...
It would be much nicer if a Ninject
kernel had a built-in way to expose its collection of Bindings, but alas it doesn't. But the bindings collection is available privately, so if you perform the correct Reflection incantations you can get hold of them. You then have to do some more Reflection to convert its Binding objects into {InterfaceType : ConcreteType}
pairs.
I'll post the minutiae of how to extract these objects from Ninject
separately, since that is orthogonal to the question of how to set up tests for DI config in general. {#Placeholder for a link to that#}
Other DI Frameworks may make this easier by providing these collections more publicly (or even by providing some sort of Validate()
method directly.)
Once you have a list of the interface that the kernel thinks it can bind, just loop over them and test out resolving each one.
Details of this will vary by Language and Testing Framework, but I use C#
and FluentAssertions
, so I assigned Action resolutionAction = (() => testKernel.Get(interfaceType))
and asserted resolutionAction.ShouldNotThrow()
or something very similar.
Validating all relevant interfaces in your codebase
The first half is all very well, but all it tells you is that the Bindings that you DI has picked up are well-defined. It doesn't tell you whether any Bindings are entirely missing.
You can cover that case by collecting all of the interesting Assemblies in your codebase:
Assembly.GetAssembly(typeof(Main.SampleClassFromMainAssembly))
Assembly.GetAssembly(typeof(Repos.SampleRepoClass))
Assembly.GetAssembly(typeof(Web.SampleController))
Assembly.GetAssembly(typeof(Other.SampleClassFromAnotherSeparateAssemblyInUse))
Then for each Assembly
reflect over its classes to find the public Interfaces that it exposes, and ensure that each of those can be resolved by the kernel.
You've got a couple of issues with this approach:
- What if you miss an Assembly, or someone adds a new Assembly, but doesn't add it to the tests?
This isn't directly a problem, but it would mean your tests don't protect you as well as you think. I put in a safety net test, to assert that every Assembly that the Ninject Kernel knows about should be in this list of Assemblies to be tested. If someone adds a new Assembly, it will likely contain something that is provided by the kernel, so this safety-net test will fail, bringing the developers attention to this test class.
- What about classes that AREN'T provided by the kernel?
I found that mainly these classes were not provided for a clear reason - maybe they're actually provided by Factory classes, or maybe the class is badly used and is manually constructed. Either way these classes were a minority and could be listed as explicit exceptions ("loop over all classes; if classname = foo then ignore it.") relatively painlessly.
Overall, this is moderately hairy. And is more fragile that I'd generally like tests to be.
But it works.
It might be something that you write before making the change, solely so that you can run it once before your change, once after the change to check that nothing's broken and then delete it?
It all works so far, because everyone happens to have called their WhateverService interface IWhateverService.
- You should be having this conversation with all the other developers before even considering any action, including discussing it here. This could be by design, and been discussed and agreed already. Whilst the pattern ofISomething
only being implemented bySomething
may seem brittle, it's possibly done on purpose to eradicate classes with names with no meaning. I personally like it in a 1-to-1 scenario as it gives you instant knowledge just from a name. – Uigur