30.09.2018 | Sami Ovaska

VALIDATING DEPENDENCY INJECTION CONFIGURATION IN .NET CORE

Every now and then some changes are needed for Dependency Injection configuration (new classes are added, scope is changed, configuration is changed and so on). I'm using .Net core, so DI is configured in Startup.cs file.

The goal is to build integration test that validates dependency injection configuration is correct. Test should be executed before deploying the application.

This article is continuation of https://zure.com/blog/validate-authentication-is-enabled-by-using-integration-test/

Source code can be found in https://github.com/sovaska/automaticintegrationtests

BUILDING REST ENDPOINT TO TEST DI CONFIGURATION

First step is to build REST endpoint for testing dependency injection configuration. Endpoint is called /api/metadata/dependencyinjection. Sample application does not have authentication, but if the following code will be used in real application, authentication needs to be added.

using Microsoft.AspNetCore.Mvc;
using Countries.Web.Contracts;
using System.Collections.Generic;
using Countries.Web.Models;

namespace Countries.Web.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class MetadataController : ControllerBase
    {
        private readonly IMetadataService _metadataService;

        public MetadataController(IMetadataService metadataService)
        {
            _metadataService = metadataService;
        }

        [HttpGet("dependencyinjection")]
        public ActionResult<DIMetadata> DependencyInjection()
        {
            var results = _metadataService.GetDependencyInjectionProblems(HttpContext.RequestServices);

            return Ok(results);
        }
    }
}

The implementation of GetDependencyInjectionProblems() can be found in MetadataService class. Function uses IServiceProvider to inject all other services in IServiceCollection than the ones having ContainsGenericParameters set to true. If injection succeeds service name is added to ValidTypes list, and if it fails it is added to Problems list with failure reason.

using System.Linq;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Internal;
using Countries.Web.Contracts;
using Countries.Web.Models;
using System.Collections.Generic;
using Microsoft.Extensions.DependencyInjection;
using System;

namespace Countries.Web.Services
{
    public class MetadataService : IMetadataService
    {
        private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
        private readonly IServiceCollection _serviceCollection;

        public MetadataService(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider,
            IServiceCollection serviceCollection)
        {
            _actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
            _serviceCollection = serviceCollection;
        }

        public DIMetadata GetDependencyInjectionProblems(IServiceProvider serviceProvider)
        {
            var result = new DIMetadata();

            foreach (var service in _serviceCollection)
            {
                var serviceType = service.ServiceType as System.Type;
                try
                {
                    if (serviceType.ContainsGenericParameters)
                    {
                        result.NotValidatedTypes.Add(new ServiceMetadata { ServiceType = serviceType.ToString(), Reason = "Type ContainsGenericParameters == true" });
                        continue;
                    }
                    var x = serviceProvider.GetService(service.ServiceType);
                    result.ValidTypes.Add(serviceType.ToString());
                }
                catch (Exception e)
                {
                    result.Problems.Add(new ServiceMetadata { ServiceType = serviceType.ToString(), Reason = e.Message });
                }
            }

            return result;
        }
    }
}

Please remember to register MetadataService in Startup class ConfigureServices -method:

services.AddSingleton<IMetadataService, MetadataService>();

You also need to register IServiceColletion as Singleton instance in Startup class ConfigureServices -method:

services.AddSingleton(services);

I created one DI problem on purpose (I injected ICountriesService in CountriesInMemoryRepository), and here are result /api/metadata/dependencyinjection endpoint returns for sample application (most of validTypes are removed to keep sample shorter):

{
    "problems": [
        {
            "serviceType": "Countries.Web.Contracts.ICountriesInMemoryRepository",
            "reason": "A circular dependency was detected for the service of type 'Countries.Web.Contracts.ICountriesInMemoryRepository'.\r\nCountries.Web.Contracts.ICountriesInMemoryRepository(Countries.Web.Repositories.CountriesInMemoryRepository) -> Countries.Web.Contracts.ICountriesService(Countries.Web.Services.CountriesService) -> Countries.Web.Contracts.ICountriesInMemoryRepository"
        },
        {
            "serviceType": "Countries.Web.Contracts.ICountriesService",
            "reason": "A circular dependency was detected for the service of type 'Countries.Web.Contracts.ICountriesService'.\r\nCountries.Web.Contracts.ICountriesService(Countries.Web.Services.CountriesService) -> Countries.Web.Contracts.ICountriesInMemoryRepository(Countries.Web.Repositories.CountriesInMemoryRepository) -> Countries.Web.Contracts.ICountriesService"
        }
    ],
    "notValidatedTypes": [
        {
            "serviceType": "Microsoft.Extensions.Options.IOptions`1[TOptions]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.Extensions.Options.IOptionsSnapshot`1[TOptions]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.Extensions.Options.IOptionsMonitor`1[TOptions]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.Extensions.Options.IOptionsFactory`1[TOptions]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.Extensions.Options.IOptionsMonitorCache`1[TOptions]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.Extensions.Logging.ILogger`1[TCategoryName]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.Extensions.Logging.Configuration.ILoggerProviderConfiguration`1[T]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper`1[TModel]",
            "reason": "Type ContainsGenericParameters == true"
        },
        {
            "serviceType": "Microsoft.Extensions.Http.ITypedHttpClientFactory`1[TClient]",
            "reason": "Type ContainsGenericParameters == true"
        }
    ],
    "validTypes": [
        "Microsoft.AspNetCore.Mvc.Cors.CorsAuthorizationFilter",
        "Microsoft.Extensions.Options.IConfigureOptions`1[Microsoft.AspNetCore.Mvc.Infrastructure.MvcCompatibilityOptions]",
        "Microsoft.Extensions.Http.HttpMessageHandlerBuilder",
        "System.Net.Http.IHttpClientFactory",
        "Microsoft.Extensions.Http.IHttpMessageHandlerBuilderFilter",
        "Countries.Web.Contracts.IRestCountriesService",
        "Microsoft.Extensions.Options.IConfigureOptions`1[Microsoft.Extensions.Http.HttpClientFactoryOptions]",
        "Microsoft.Extensions.Options.IConfigureOptions`1[Microsoft.Extensions.Http.HttpClientFactoryOptions]",
        "Countries.Web.Contracts.IMetadataService",
        "Microsoft.Extensions.DependencyInjection.IServiceCollection"
    ]
}

This metadata will be used in integration test, and if there are any items in Problems -list, test will fail.

BUILDING INTEGRATION TEST THAT USES METADATA ENDPOINT

I'm using XUnit as test framework.

It's important that test specific startup class used in original article will not be used in this test. It is mandatory to test DI as it will be in application hosted in production.

BASE CLASS FOR CONTROLLER TESTS

I extended original base class for tests with ReadDependencyInjectionMetadataAsync() function. Function will call /api/metadata/dependencyinjection endpoint:

protected async Task<DIMetadata> ReadDependencyInjectionMetadataAsync()
{
    using (var msg = new HttpRequestMessage(HttpMethod.Get, BuildUri("Metadata", parameters: "/dependencyinjection").ToString()))
    {
        using (var response = await Client.SendAsync(msg).ConfigureAwait(false))
        {
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            if (!response.IsSuccessStatusCode)
            {
                return null;
            }

            ValidateHeaders(response.Headers);

            var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
            if (string.IsNullOrEmpty(content))
            {
                return null;
            }

            return JsonConvert.DeserializeObject<DIMetadata>(content);
        }
    }
}

TEST CLASS

The test class doesn't do much, it just

  • gets dependency injection metadata by using base class
  • checks there are no items in problems list

Here is the implementation of test class:

using Xunit;
using System.Threading.Tasks;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Testing;

namespace Countries.Tests.ControllerTests
{
    public class DependencyInjectionTests : ControllerTestBase, IClassFixture<WebApplicationFactory<Web.Startup>>
    {
        public DependencyInjectionTests(WebApplicationFactory<Web.Startup> factory)
            : base(factory.CreateClient())
        {
        }

        [Fact]
        public async Task Test()
        {
            var diMetadata = await ReadDependencyInjectionMetadataAsync().ConfigureAwait(false);

            Assert.NotNull(diMetadata);
            Assert.True(!diMetadata.Problems.Any());
        }
    }
}

When test is executed, it succeeds since dependency injection configuration in sample application is correct.