Override Ninject Binding Only For a Single Test

The topic of today's post is quite controversial - for a good reason. The general advice is to avoid using IoC containers in your test code altogether. If you manage to do that, you'll never need to change their configuration in between tests. Unfortunately achieving that when IoC usage is being retrofitted into an existing application, can be challenging. Under such circumstances it might make sense to settle with a suboptimal solution which will require the IoC container to be configured appropriately in tests as well.

Taking this approach will quickly result in having to reconfigure it for some of the tests. Let's take a look at some code to see why.

public PersonDto InsertPerson(PersonDto personDto)
{
    if (!_validator.Validate(personDto).IsValid)
    {
        throw new ArgumentException("personDto");
    }
    var personModel = _mapper.Map(personDto);
    _repository.Insert(personModel);
    _repository.Save();
    return _mapper.Map(personModel);
}

You're likely to see similar code in many LOB applications. The method has 3 dependencies: a validator, a mapper and a repository. Usually you'll want to initialize them using constructor injection:

private readonly IValidator<PersonDto> _validator;
private readonly IMapper<PersonDto, PersonModel> _mapper;
private readonly IRepository<PersonModel> _repository;

public PersonManagementService(
    IValidator<PersonDto> validator,
    IMapper<PersonDto, PersonModel> mapper,
    IRepository<PersonModel> repository)
{
    _validator = validator;
    _mapper = mapper;
    _repository = repository;
}

This makes it really easy to configure even without an IoC container.

[Test]
public void ValidPersonIsInsertedIntoRepository()
{
    var validator = new PersonValidator();
    var mapper = new PersonMapper();
    var repository = new PersonRepository();
    var service = new PersonManagementService(validator, mapper, repository);
    var original = new PersonDto();

    var inserted = service.InsertPerson(original);

    inserted.ShouldBeEquivalentTo(original);
}

As you can see the test can simply instantiate each dependency and pass it to the service constructor. If any of the dependencies should need to be mocked, a different class implementing the required interface can be instantiated instead; e.g. the validator in the above test should already be tested elsewhere, so it could be mocked to easily make the validation pass or fail without having to cause that by creating a suitable DTO:

var validator = new MockValidator<PersonDto>(isValid: true);

Now imagine that the InsertPerson method is in a class which doesn't give you full control of its instantiation, but you still want the dependencies to be injected into it in some way. Here's one way to do it:

private readonly IValidator<PersonDto> _validator =
    NinjectKernel.Instance.Get<IValidator<PersonDto>>();
private readonly IMapper<PersonDto, PersonModel> _mapper =
    NinjectKernel.Instance.Get<IMapper<PersonDto, PersonModel>>();
private readonly IRepository<PersonModel> _repository =
    NinjectKernel.Instance.Get<IRepository<PersonModel>>();

Instead of being injected through constructor, the dependencies are now initialized by a call to an IoC container singleton. In the startup code this container can be initialized as needed:

NinjectKernel.Instance.Bind<IValidator<PersonDto>>().To<PersonValidator>();
NinjectKernel.Instance.Bind<IMapper<PersonDto, PersonModel>>().To<PersonMapper>();
NinjectKernel.Instance.Bind<IRepository<PersonModel>>().To<PersonRepository>();

Of course you can define different bindings in your test project, but since the container is a singleton, you don't have all that many options to vary these bindings between tests. The most obvious way would be to rebind a specific binding inside the test:

var validator = new MockValidator<PersonDto>(isValid: true);
NinjectKernel.Instance.Rebind<IValidator<PersonDto>>().ToConstant(validator);

Unfortunately you can't just revert back to the previous binding. If you don't do it, this test will a have a side effect: all tests run after it will use the new binding. To avoid that, you could initialize all the bindings before each test. Since there's no simple way to reset a kernel in Ninject, you only have 2 options left:

  • Using only Rebind calls instead of Bind for initialization and avoiding loading modules because you can't load them twice in the same kernel.
  • Create a new singleton instance and initialize it from scratch.

I recently found myself in a similar situation and didn't really like any of the options. After some investigation I found an alternative approach based on my previous post about object scoping. I took advantage of contextual binding support in Ninject:

var validator = new MockValidator<PersonDto>(isValid: true);

var testName = TestContext.CurrentContext.Test.FullName;
NinjectKernel.Instance.Bind<IValidator<PersonDto>>()
    .ToConstant(validator)
    .When(_ => TestContext.CurrentContext.Test.FullName == testName);

The above code also depends on NUnit's TestContext which ensures a unique name for each test. This way the added binding is only going to be valid for the duration of the test. Once I got it working, I wrapped the code in a simple to use extension method:

public static IBindingInNamedWithOrOnSyntax<T>
    WhenInCurrentTest<T>(this IBindingWhenSyntax<T> binding)
{
    var testName = TestContext.CurrentContext.Test.FullName;
    return binding.When(_ =>
        TestContext.CurrentContext.Test.FullName == testName);
}

With its help the intent of the test code becomes much more obvious:

var validator = new MockValidator<PersonDto>(isValid: true);
NinjectKernel.Instance.Bind<IValidator<PersonDto>>()
    .ToConstant(validator).WhenInCurrentTest();

I still prefer not having to use IoC containers in my tests, but at least I made it more bearable when I can't avoid it.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License