Skip to content

Cannot combine WebApplicationFactory with customized dependency injection via ClassConstructor #2075

Closed as not planned

Description

@BramMusters

First of all, thanks for this package, looks very promising!

Problem

I want to test my ASP.NET Core application using WebApplicationFactory, similar to this example: https://thomhurst.github.io/TUnit/docs/examples/aspnet
That works. Then I want to inject dependencies (data factories) in my Test classes to easily create data. I know I can do that using ClassDataSource, but this only allows parameterless constructors. As soon as I add parameters (in my case, dependencies) the build fails with "must be a non-abstract type with a public parameterless constructor in order to use it as parameter 'T' in the generic class".
Let's take this Factory as an example: It uses the AppDbContext that is used and registered throughout the API that is tested.

public class TestFactory
{
    private readonly AppDbContext _dbContext;
    
    public TestFactory(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    // Methods that use the DbContext
}

As far as I understand, that issue can be solved using Class Constructor Helpers to allow creation of classes with parameters. When I take this as an example the unit test starts correctly.
But the test fails at runtime, because the ServiceCollection from the DiClassConstructor and the ServiceCollection from the regular API are not the same. The TestFactory receives a DbContext, but it's not the same DbContext that is used by the WebApplicationFactory, so changes made through one aren't visible to the other. Am I missing something here? Having all dependencies of the API available would be awesome, but I am not sure if it's possible at all..

Reproduction

// MinimalApi/Program.cs
using Microsoft.EntityFrameworkCore;
using MinimalApi.Data;

var builder = WebApplication.CreateBuilder(args);

// Add DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseInMemoryDatabase("TestDb"));

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

// Enable testing
public partial class Program {}

// MinimalApi/Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace MinimalApi.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
    
    public DbSet<User> Users => Set<User>();
}

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
}

// TestProject/DataServices/TestFactory.cs
using MinimalApi.Data;

namespace TestProject.DataServices;

public class TestFactory
{
    private readonly AppDbContext _dbContext;
    
    public TestFactory(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }
    
    public async Task CreateUserAsync(string name)
    {
        _dbContext.Users.Add(new User { Name = name });
        await _dbContext.SaveChangesAsync();
    }
    
    public async Task<IEnumerable<User>> GetAllUsersAsync()
    {
        return await _dbContext.Users.ToListAsync();
    }
}

// TestProject/AppFactory.cs
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using MinimalApi;
using MinimalApi.Data;
using TUnit.Core.Interfaces;

namespace TestProject;

public class AppFactory : IAsyncInitializer, IAsyncDisposable
{
    public WebApplicationFactory<Program>? Factory { get; private set; }

    public async Task InitializeAsync()
    {
        Factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // test config
            });
        });
        
        await Task.CompletedTask;
    }

    public async ValueTask DisposeAsync()
    {
        if (Factory != null)
        {
            await Factory.DisposeAsync();
        }
    }
}

// TestProject/DiClassConstructor.cs
using Microsoft.Extensions.DependencyInjection;
using TestProject.DataServices;
using TUnit.Core;
using TUnit.Core.Interfaces;

namespace TestProject;

public class DiClassConstructor : IClassConstructor, ITestEndEventReceiver
{
    private static readonly IServiceProvider _serviceProvider;
    private AsyncServiceScope _scope;

    static DiClassConstructor()
    {
        var services = new ServiceCollection();
        
        // Register TestFactory which depends on AppDbContext
        services.AddTransient<TestFactory>();
        
        _serviceProvider = services.BuildServiceProvider();
    }

    public T Create<T>(ClassConstructorMetadata classConstructorMetadata) where T : class
    {
        _scope = _serviceProvider.CreateAsyncScope();
        return ActivatorUtilities.CreateInstance<T>(_scope.ServiceProvider);
    }

    public ValueTask OnTestEnd(TestContext testContext)
    {
        return _scope.DisposeAsync();
    }
}

// TestProject/UserTests.cs
using Microsoft.AspNetCore.Mvc.Testing;
using MinimalApi;
using TestProject.DataServices;
using TUnit.Core;

namespace TestProject;

[ClassConstructor<DiClassConstructor>]
public class UserTests
{
    private readonly TestFactory _testFactory;
    
    public UserTests(TestFactory testFactory)
    {
        _testFactory = testFactory;
    }
    
    [Test]
    [ClassDataSource<AppFactory>(Shared = SharedType.PerTestSession)]
    public async Task CreateUser_ShouldAddUserToDatabase(AppFactory appFactory)
    {
        // This test will fail because TestFactory has no valid AppDbContext
        // The AppDbContext is from the AppFactory but DiClassConstructor doesn't know about it
        await _testFactory.CreateUserAsync("Test User");
        
        // We'd need to get a new TestFactory instance with the correct DbContext
        // But there's no clean way to do this with the current TUnit architecture
        var users = await _testFactory.GetAllUsersAsync();
        
        // This assertion will fail because TestFactory has a different AppDbContext
        Assert.AreEqual(1, users.Count());
    }
}

Activity

  1. thomhurst commented on Mar 12, 2025

    @thomhurst
    Owner

    So you essentially want to inject in the same AppDbContext that is in your WebFactory, into your tests too, so that you can manipulate it?

  2. BramMusters commented on Mar 13, 2025

    @BramMusters
    Author

    Yes. Not for manipulation but for full integration testing. I want to inject other dependencies as well. For example:

        public CompanyDataFactory(
            AppDbContext db,
            IFirebaseClient firebaseClient,
            // Or any other dependency that is registered in the regular app.
        )
        {

    Whether I replace the dependency with a mock or anything else is up to the AppFactory

  3. thomhurst commented on Mar 13, 2025

    @thomhurst
    Owner

    Can you play about with something like this?

    Make sure you're on v0.18.21

    using Microsoft.AspNetCore.Mvc.Testing;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.DependencyInjection;
    using TUnit.Core.Interfaces;
    
    namespace TestProject;
    
    public class WebApplicationFactory : WebApplicationFactory<Program>, IAsyncInitializer
    {
        public Task InitializeAsync()
        {
            _ = Server;
    
            return Task.CompletedTask;
        }
    }
    
    public class FromWebApplicationFactoryAttribute : NonTypedDataSourceGeneratorAttribute
    {
        public Task InitializeAsync()
        {
            return Task.CompletedTask;
        }
    
        public override IEnumerable<Func<object?[]?>> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
        {
            if (dataGeneratorMetadata.ClassInstanceArguments is null)
            {
                throw new Exception("ClassInstanceArguments is null");
            }
    
            if (dataGeneratorMetadata.ClassInstanceArguments.OfType<WebApplicationFactory<Program>>().FirstOrDefault() is not
                { } webApplicationFactory)
            {
                throw new Exception("WebApplicationFactory is not part of the class constructor arguments");
            }
            
            var serviceProvider = webApplicationFactory.Server.Services;
            
            var scope = serviceProvider.CreateAsyncScope();
    
            dataGeneratorMetadata.TestBuilderContext.Current.Events.OnDispose += async (_, _) =>
            {
                await scope.DisposeAsync();
            };
            
            yield return () =>
            {
                return dataGeneratorMetadata.MembersToGenerate.Select(x => ActivatorUtilities.GetServiceOrCreateInstance(scope.ServiceProvider, x.Type)).ToArray();
            };
        }
    }
    
    [ClassDataSource<WebApplicationFactory<Program>>(Shared = SharedType.PerTestSession)]
    public class MyTests(WebApplicationFactory<Program> factory)
    {
        [FromWebApplicationFactory]
        public required DbContext DbContext { get; init; }
        
        [FromWebApplicationFactory]
        public required IFirebaseClient FirebaseClient { get; init; }
    
        [Test]
        public void Test()
        {
            ...
        }
    }
  4. thomhurst commented on Mar 17, 2025

    @thomhurst
    Owner
  5. BramMusters commented on Mar 18, 2025

    @BramMusters
    Author

    Excuse my late response!

    The build fails (0.18.40, .NET 8), with these errors:
    Cannot implicitly convert type 'object[]' to X.DbContext
    Cannot implicitly convert type 'object[]' to X.Firebase.IFirebaseClient

  6. thomhurst commented on Mar 18, 2025

    @thomhurst
    Owner

    Can you try v0.18.45

  7. thomhurst commented on Mar 24, 2025

    @thomhurst
    Owner
  8. BramMusters commented on Mar 25, 2025

    @BramMusters
    Author

    Sorry, you are just too fast;) . Yes, that works for the provided code, thanks!

    However, in my very specific use case it does not work yet. I am not sure if it is even possible to support, but here is the situation anyway.

    1. I use Integresql to spin up a postgres database per test.
    2. Therefore, my WebFactory is not shared (Shared = SharedType.None). That seems to work.
    3. But, I need to override the connection string in the configureWebHostso that it connects to the specific DB.
     protected override void ConfigureWebHost(IWebHostBuilder builder)
        {
            // Configure app setup with async connection string retrieval
            builder.ConfigureAppConfiguration((context, configBuilder) =>
            {
                var testName = TestContext.Current?.TestDetails.TestName;
                connectionString = // get connectionString string
                // set connectionstring to app iConfiguration
                configBuilder.AddInMemoryCollection(new Dictionary<string, string>
                {
                    { "ConnectionStrings:DefaultConnection", connectionString }
                }!);
            });
    
            builder.ConfigureTestServices(services =>
            {
                // Add Test services
            });
        }
    1. The testName is used for the connectionString. However it is null. Also, I noticed that ConfigureWebHost is called 3 times, I guess it should only be called once?

    Once again, I am not sure if this approach will work at all, but just to let you know :)

  9. thomhurst commented on Mar 28, 2025

    @thomhurst
    Owner

    Test name seems to work for me:

    Image

    Here's the code:

    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.Mvc.Testing;
    using Microsoft.Extensions.Configuration;
    using TUnit.Core.Interfaces;
    
    namespace TUnit.Example.Asp.Net.TestProject;
    
    public class WebApplicationFactory : WebApplicationFactory<Program>, IAsyncInitializer
    {
        public Task InitializeAsync()
        {
            _ = Server;
    
            return Task.CompletedTask;
        }
    
        protected override void ConfigureWebHost(IWebHostBuilder builder)
        {        
            builder.ConfigureAppConfiguration((context, configBuilder) =>
            {
                var testName = TestContext.Current!.TestDetails.TestName;
                
                var connectionString = testName + "-DummyConnectionString";
                
                configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
                {
                    { "ConnectionStrings:DefaultConnection", connectionString }
                });
            });
        }
    }

    Implementing IAsyncInitializer and accessing the Server property will force the server to initialize. This will be done by one thread, and so should prevent multiple threads trying to do it, which I think might be why you're seeing it hit 3 times.

  10. github-actions commented on Apr 28, 2025

    @github-actions
    Contributor

    This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.

  11. github-actions commented on May 4, 2025

    @github-actions
    Contributor

    This issue was closed because it has been stalled for 5 days with no activity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions