Safely making wide-reaching change to IoC/DI config
Asked Answered
O

1

1

Specific Question: How can I unit Test my DI configuration against my codebase to ensure that all the wiring up still works after I make some change to the automated binding detection.


I've been contributing to a small-ish codebase (maybe ~10 pages? and 20-30 services/controllers) which uses Ninject for Ioc/DI.

I've discovered that in the Ninject Kernel it is configured to BindDefaultInterface. That means that if you ask it for an IFoo, it will go looking for a Foo class.

But it does that based on the string pattern, not the C# inheritance. That means that MyFoo : IFoo won't bind, and you could also get other weird "coincidental" bindings, maybe?

It all works so far, because everyone happens to have called their WhateverService interface IWhateverService.

But this seems enormously brittle and unintuitive to me. And it specifically broke when I wanted to rename my live FilePathProvider : IFilePathProvider to be AppSettingsBasedFilePathProvider (as opposed to the RootFolderFilePathProvider, or the NCrunchFilePathProvider which get used in Test) on the basis of that telling you what it did :)

There are a couple of alternative configurations:

  • BindToDefaultInterfaces (note plural) which will bind MyOtherBar to IMyOtherBar, IOtherBar & IBar (I think)
  • BindToSingleInterface works if every class implements exactly 1 interface.
  • BindToAllInterfaces does exactly what it sounds like.

I'd like to change to those, but I'm concerned about introducing obscure bugs whereby some class somewhere stops binding in the way that it should, but I don't notice.

Is there any way to test this / make this change with a reasonable amount of safety (i.e. more than "do it and hope", anyway!) without just trying to work out how to excercise EVERY possible component.

Onions answered 15/12, 2017 at 15:30 Comment(16)
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 of ISomething only being implemented by Something 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
@Archer Heya, a bunch of responses both practical and on-principle... 1) This is a slow-burn toy project that has been created over several years, with a handful of contributors, none of whom worked on it simultaneously or with any great amount of planning. It's been repeatedly untouched for multi-month periods, and no-one had touched the IoC/DI in over 2 years. So it seems extremely unlikely that this convention has been planned or discussed. I do agree that "talk to people" is the correct approach on a more active project.Onions
@Archer 2) I don't accept "you should be discussing it before asking questions on SO". That's just silly - this question is interesting and informative even you end up not doing the action. No-one ever needs permission to discuss the technology of a solution.Onions
@Archer 3) Forcing a particular naming pattern MUST be used is definitely unnecessary to "eradicate classes with names with no meaning". If Foo and IFoo are sufficient names, you can use then whether or not your DI forces it ... and if their not sufficient then forcing the naming is actively harming your codebase.Onions
@Archer 4) "I like it in a 1-to-1 scenario". I roughly agree, but as I said, this is specifically NOT a 1-to-1 scenario. The point is that I was to distinguish between my various implementations :)Onions
To those who voted to close, could I have an explanation, so that I can improve the question where necessary?Onions
@Onions I didn't downvote (yet?) but I guess your question is not on topic as it's more about a practices issue, so it might better fit softwareengineering.stackexchange.com or some other stack exchange network.Jackiejackinoffice
Also, what side-effects or "weird bindings" do you expect? Worst case, Ninject will throw a run-time exception when trying to instanciate a class. Either because there's no matching binding, there's too many bindings or it fails to cast a type to an interface it doesn't actually implement...Jackiejackinoffice
So generally the safe way to refactor is: write tests. Then refactor.Jackiejackinoffice
@Jackiejackinoffice If your site gives run-time errors, then it is broken! Yes, the solution is to test it. "How can I test this to ensure it isn't broken" is literally my question!?Onions
@Onions as DI is part of the "composition root" this is something that can basically only be tested by manual testing or by automated system tests. Means you'll have to test all possible components. Some other DI frameworks have methods to check whether all registered components can be created (all dependencies registered). That made have things easier. Ninject, however, does not support this.Jackiejackinoffice
@Jackiejackinoffice FYI see below for how you can indeed test the composition root :)Onions
@Onions It's great you found a way that is good enough for your refactoring. However, how do you know that you're not changing bindings in a way that breaks the application? For example, changing from 'InSingletonScope()' to 'InTransientScope()' - it'll still resolve but it might break your application.Jackiejackinoffice
@Jackiejackinoffice Agreed - my testing doesn't go anywhere near that. OTOH I suspect that one could construct code to excercise such behaviour if it were necessary?Onions
@Onions If i remember correctly Simple Injector has some built in checks like these (see here). However, it can not know about application (business-) requirements, and therefore there's plenty of checks it can't do unless you'd give that system ample information. However, it's not unlikely that at that point it's more economical to create system/component tests that test a whole lot more, too, instead of just testing the containers configuration...Jackiejackinoffice
@Onions also this post by Mark Seeman covers some interesting points in regards to this topic / the question you asked. Simple Injectors verification goes much further and adds more benefit.Jackiejackinoffice
O
2

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:

  1. 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.

  1. 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?

Onions answered 6/1, 2018 at 14:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.