Hiromi
Hiromi

Reputation: 21

Only conditionally add DbContext in REST API application

I'm making a ASP.NET Core Web API with C#, but when I try to setup a mock of the web application in my NUnit test project, I'm encountering a problem. When I run my test I get the following message:

System.InvalidOperationException: Services for database providers 'Microsoft.EntityFrameworkCore.SqlServer', 'Microsoft.EntityFrameworkCore.InMemory' have been registered in the service provider. Only a single database provider can be registered in a service provider. If possible, ensure that Entity Framework is managing its service provider by removing the call to 'UseInternalServiceProvider'. Otherwise, consider conditionally registering the database provider, or maintaining one service provider per database provider.

Which makes sense, since the REST API uses SQL Server and I want to use InMemory for the unit tests.

public partial class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        var logger = LoggerFactory.Create(logging => logging.AddConsole()).CreateLogger<Program>();
        var isUnitTest = builder.Configuration.GetValue<bool>("IsUnitTest");
        
        logger.LogInformation("IsUnitTest: {IsUnitTest}", isUnitTest);

        if (isUnitTest)
        {
            builder.Services.AddDbContext<API.Context.APIContext>(options => options.UseInMemoryDatabase("InMemoryDbForTesting"));
        }
        else
        {
            var conString = builder.Configuration.GetConnectionString("DatabaseConnection") ??
                throw new InvalidOperationException("Connection string 'DatabaseConnection' not found.");
            builder.Services.AddDbContext<API.Context.APIContext>(options => options.UseSqlServer(conString));
        }

        // Add services to the container.
        builder.Services.AddControllers();

        // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
        builder.Services.AddOpenApi();
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen();

        var app = builder.Build();

        // Configure the HTTP request pipeline.
        if (app.Environment.IsDevelopment())
        {
            app.MapOpenApi();
        }

        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        };

        app.UseHttpsRedirection();
        app.UseAuthorization();
        app.MapControllers();
        app.MapServiceEndpoints();
        app.MapScanEndpoints();
        app.MapPackageEndpoints();
        app.MapImageEndpoints();

        app.Run();
    }
}

The unit test setup:

private WebApplicationFactory<Program>? _factory;
private HttpClient? _client;

[SetUp]
public void Setup()
{
    _factory = new WebApplicationFactory<Program>()
        .WithWebHostBuilder(builder =>
        {
            builder.ConfigureAppConfiguration((context, config) =>
            {
                var settings = new Dictionary<string, string>
                {
                    { "IsUnitTest", "true" }
                };
                config.AddInMemoryCollection(settings);
            });

            builder.ConfigureServices(services =>
            {
                // Remove all DbContextOptions<APIContext> descriptors
                var descriptors = services.Where(d => d.ServiceType == typeof(DbContextOptions<APIContext>)).ToList();
                foreach (var descriptor in descriptors)
                {
                    services.Remove(descriptor);
                }

                services.AddDbContext<APIContext>(options =>
                {
                    options.UseInMemoryDatabase("InMemoryDbForTesting");
                });

                // Add logging
                services.AddLogging(configure => configure.AddConsole());
            });
        });

    _client = _factory.CreateClient();
}

I made a condition in the Program class, so it wouldn't add SQL Server when running unit tests, but the config value is returned as null.

My question is: why doesn't it get the config I pass to it?

Here's my code:

var isUnitTest = builder.Configuration.GetValue<bool>("IsUnitTest");

isUnitTest is null when running the unit test.

I tried adding code to remove the SQL Server descriptor from services, when running the unit test setup, but it still remains.

What can I do to only conditionally add SQL Server when starting the API application in the Program class?

Upvotes: 2

Views: 81

Answers (2)

Hiromi
Hiromi

Reputation: 21

Changing

var descriptors = services.Where(d => d.ServiceType == typeof(DbContextOptions<APIContext>)).ToList();

To

var descriptors = services.Where(d => d.ServiceType == typeof(IDbContextOptionsConfiguration<APIContext>)).ToList();

Did the trick. I don't know if it's the best solution, but it works.

Edit: Although, now several of the tests that worked just fine when I commented out the SqlServer and only added the InMemory DB, fail. So I guess it's not really the best way.

Upvotes: 0

Xavier J
Xavier J

Reputation: 4728

How I handle this is to move all dependency configuration code, EXCEPT for database configuration, to a third VS project separate from my main application and unit test project. This third VS project has to be able to reach any required dependencies (services and other classes).

Please note that in my unit test projects, I am testing with a self-generated service container, and I'm not calling controller endpoints to test code but calling methods within instances of services that need testing. In these cases MVC gets skipped completely, but it makes for much simpler testing. (Your mileage may vary)

Code looks like:

public static class StartupHelper
{
   /// <summary>
   ///     This is shared between webapi and unit test project.
   ///     It is called BEFORE a service provider has been generated
   //      place any code that's needed by both webapi and unit tests here.
   /// </summary>
   /// <param name="serviceCollection"></param>
   /// <param name="configuration"></param>
   public static void ConfigureServiceCollection(IServiceCollection serviceCollection, IConfiguration configuration)
   {
       //register services
       serviceCollection.AddTransient<ISomeInterface, SomeImplementationClass>();
   }

   /// <summary>
   ///     this is called when a service provider has been generated.
   //      place any code that's needed by both webapi and unit tests here.
   /// </summary>
   /// <param name="serviceProvider"></param>
   public static void ConfigureServiceProvider(IServiceProvider serviceProvider)
   {
   }
}

In my program startup code, I call StartupHelper.ConfigureServiceCollection with the current IServiceCollection and IConfiguration instances. This occurs before calling builder.Build();, like this:

IApplicationBuilder app = builder.Build();

As I mentioned above, the database context config code remains in the startup code. Next, there's a second method call to StartupHelper.ConfigureServiceProvider. This one's optional but it looks like this:

StartupHelper.ConfigureServiceProvider(app.ApplicationServices);

The unit test code initializes its own service collection, initializes an in-memory database context, and also calls the methods in the class StartupHelper. I've had a lot of success with this approach across several WebAPI projects.

Upvotes: 0

Related Questions