Search code examples
c#asp.net-coreintegration-testingasp.net-core-identity

integration testing with .net core and identity framework


I just can't find any answers to this anywhere. I have read multiple articles and looked at loads of source code, but none of it seems to help.

http://www.dotnetcurry.com/aspnet-core/1420/integration-testing-aspnet-core

https://www.davepaquette.com/archive/2016/11/27/integration-testing-with-entity-framework-core-and-sql-server.aspx

https://learn.microsoft.com/en-us/aspnet/core/testing/integration-testing

The issue I have is resolving services instead of using HttpClient to test controllers. This is my startup class:

public class Startup: IStartup
{
    protected IServiceProvider _provider;
    private readonly IConfiguration _configuration;
    public Startup(IConfiguration configuration) => _configuration = configuration;

    // This method gets called by the runtime. Use this method to add services to the container.
    public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        services.Configure<MvcOptions>(options => options.Filters.Add(new RequireHttpsAttribute()));

        SetUpDataBase(services);
        services.AddMvc();
        services
            .AddIdentityCore<User>(null)
            .AddDefaultTokenProviders();
        return services.BuildServiceProvider();
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app)
    {
        var options = new RewriteOptions().AddRedirectToHttps();

        app.UseRewriter(options);
        app.UseAuthentication();
        app.UseMvc();

        using(var scope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
        {
            var context = scope.ServiceProvider.GetService<DatabaseContext>();
            EnsureDatabaseCreated(context);
        }
    }

    protected virtual void SetUpDataBase(IServiceCollection services) => services.AddDbContext(_configuration);

    protected virtual void EnsureDatabaseCreated(DatabaseContext dbContext)
    {
        dbContext.Database.Migrate();
    }
}

Then in my integration tests, I created 2 setup classes. The first is a TestStartup:

public class TestStartup: Startup, IDisposable
{

    private const string DatabaseName = "vmpyr";

    public TestStartup(IConfiguration configuration) : base(configuration)
    {
    }

    protected override void EnsureDatabaseCreated(DatabaseContext dbContext)
    {
        DestroyDatabase();
        CreateDatabase();
    }

    protected override void SetUpDataBase(IServiceCollection services)
    {
        var connectionString = Database.ToString();
        var connection = new SqlConnection(connectionString);
        services
            .AddEntityFrameworkSqlServer()
            .AddDbContext<DatabaseContext>(
                options => options.UseSqlServer(connection)
            );
    }

    public void Dispose()
    {
        DestroyDatabase();
    }

    private static void CreateDatabase()
    {
        ExecuteSqlCommand(Master, $@"Create Database [{ DatabaseName }] ON (NAME = '{ DatabaseName }', FILENAME = '{Filename}')");
        var connectionString = Database.ToString();
        var optionsBuilder = new DbContextOptionsBuilder<DatabaseContext>();
        optionsBuilder.UseSqlServer(connectionString);
        using (var context = new DatabaseContext(optionsBuilder.Options))
        {
            context.Database.Migrate();
            DbInitializer.Initialize(context);
        }
    }

    private static void DestroyDatabase()
    {
        var fileNames = ExecuteSqlQuery(Master, $@"SELECT [physical_name] FROM [sys].[master_files] WHERE [database_id] = DB_ID('{ DatabaseName }')", row => (string)row["physical_name"]);
        if (!fileNames.Any()) return;
        ExecuteSqlCommand(Master, $@"ALTER DATABASE [{ DatabaseName }] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; EXEC sp_detach_db '{ DatabaseName }'");
        fileNames.ForEach(File.Delete);
    }

    private static void ExecuteSqlCommand(SqlConnectionStringBuilder connectionStringBuilder, string commandText)
    {
        using (var connection = new SqlConnection(connectionStringBuilder.ConnectionString))
        {
            connection.Open();
            using (var command = connection.CreateCommand())
            {
                command.CommandText = commandText;
                command.ExecuteNonQuery();
            }
        }
    }

    private static List<T> ExecuteSqlQuery<T>(SqlConnectionStringBuilder connectionStringBuilder, string queryText, Func<SqlDataReader, T> read)
    {
        var result = new List<T>();
        using (var connection = new SqlConnection(connectionStringBuilder.ConnectionString))
        {
            connection.Open();
            using (var command = connection.CreateCommand())
            {
                command.CommandText = queryText;
                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        result.Add(read(reader));
                    }
                }
            }
        }
        return result;
    }

    private static SqlConnectionStringBuilder Master => new SqlConnectionStringBuilder
    {
        DataSource = @"(LocalDB)\MSSQLLocalDB",
        InitialCatalog = "master",
        IntegratedSecurity = true
    };

    private static SqlConnectionStringBuilder Database => new SqlConnectionStringBuilder
    {
        DataSource = @"(LocalDB)\MSSQLLocalDB",
        InitialCatalog = DatabaseName,
        IntegratedSecurity = true
    };

    private static string Filename => Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), $"{ DatabaseName }.mdf");
}

That handles all my db creation and configuration of services. The second is my TestFixture class:

public class TestFixture<TStartup> : IDisposable where TStartup : class
{
    private readonly IServiceScope _scope;
    private readonly TestServer _testServer;

    public TestFixture()
    {
        var webHostBuilder = new WebHostBuilder().UseStartup<TStartup>();  

        _testServer = new TestServer(webHostBuilder);
        _scope = _testServer.Host.Services.CreateScope();
    }

    public TEntity Resolve<TEntity>() => _scope.ServiceProvider.GetRequiredService<TEntity>();

    public void Dispose()
    {
        _scope.Dispose();
        _testServer.Dispose();
    }
}

This (as you can see) creates the test server, but also exposes a Resolve method that should resolve my services. Now came my tests. I created a UserContext class, which looks like this:

public class UserContext
{
    private readonly UserManager<User> _userManager;
    private UserContext(TestFixture<TestStartup> fixture) => _userManager = fixture.Resolve<UserManager<User>>();

    public static UserContext GivenServices() => new UserContext(new TestFixture<TestStartup>());

    public async Task<User> WhenCreateUserAsync(string email)
    {
        var user = new User
        {
            UserName = email,
            Email = email
        };
        var result = await _userManager.CreateAsync(user);
        if (!result.Succeeded)
            throw new Exception(result.Errors.Join(", "));
        return user;
    }

    public async Task<User> WhenGetUserAsync(string username) => await _userManager.FindByNameAsync(username);
}

And then I created a test:

[TestFixture]
public class UserManagerTests
{

    [Test]
    public async Task ShouldCreateUser()
    {
        var services = UserContext.GivenServices();
        await services.WhenCreateUserAsync("tim@tim.com");
        var user = await services.WhenGetUserAsync("tim@tim.com");
        user.Should().NotBe(null);
    }
}

Unfortunately, it errors out when I run the test and states:

Message: System.InvalidOperationException : Unable to resolve service for type 'Microsoft.AspNetCore.Identity.IUserStore1[vmpyr.Data.Models.User]' while attempting to activate 'Microsoft.AspNetCore.Identity.UserManager1[vmpyr.Data.Models.User]'.

I think this is telling me that, although it found my UserManager service, it couldn't find the UserStore dependency that is used in the constructor. I have looked at the services.AddIdentityCore<User>(null) and can see it doesn't appear the register the UserStore:

public static IdentityBuilder AddIdentityCore<TUser>(this IServiceCollection services, Action<IdentityOptions> setupAction) where TUser : class
{
  services.AddOptions().AddLogging();
  services.TryAddScoped<IUserValidator<TUser>, UserValidator<TUser>>();
  services.TryAddScoped<IPasswordValidator<TUser>, PasswordValidator<TUser>>();
  services.TryAddScoped<IPasswordHasher<TUser>, PasswordHasher<TUser>>();
  services.TryAddScoped<ILookupNormalizer, UpperInvariantLookupNormalizer>();
  services.TryAddScoped<IdentityErrorDescriber>();
  services.TryAddScoped<IUserClaimsPrincipalFactory<TUser>, UserClaimsPrincipalFactory<TUser>>();
  services.TryAddScoped<UserManager<TUser>, UserManager<TUser>>();
  if (setupAction != null)
    services.Configure<IdentityOptions>(setupAction);
  return new IdentityBuilder(typeof (TUser), services);
}

I then looked at the .AddIdentity<User, IdentityRole>() method and that also doesn't seem to register the UserStore:

public static IdentityBuilder AddIdentity<TUser, TRole>(this IServiceCollection services, Action<IdentityOptions> setupAction) where TUser : class where TRole : class
{
  services.AddAuthentication((Action<AuthenticationOptions>) (options =>
  {
    options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
    options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
  })).AddCookie(IdentityConstants.ApplicationScheme, (Action<CookieAuthenticationOptions>) (o =>
  {
    o.LoginPath = new PathString("/Account/Login");
    o.Events = new CookieAuthenticationEvents()
    {
      OnValidatePrincipal = new Func<CookieValidatePrincipalContext, Task>(SecurityStampValidator.ValidatePrincipalAsync)
    };
  })).AddCookie(IdentityConstants.ExternalScheme, (Action<CookieAuthenticationOptions>) (o =>
  {
    o.Cookie.Name = IdentityConstants.ExternalScheme;
    o.ExpireTimeSpan = TimeSpan.FromMinutes(5.0);
  })).AddCookie(IdentityConstants.TwoFactorRememberMeScheme, (Action<CookieAuthenticationOptions>) (o => o.Cookie.Name = IdentityConstants.TwoFactorRememberMeScheme)).AddCookie(IdentityConstants.TwoFactorUserIdScheme, (Action<CookieAuthenticationOptions>) (o =>
  {
    o.Cookie.Name = IdentityConstants.TwoFactorUserIdScheme;
    o.ExpireTimeSpan = TimeSpan.FromMinutes(5.0);
  }));
  services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
  services.TryAddScoped<IUserValidator<TUser>, UserValidator<TUser>>();
  services.TryAddScoped<IPasswordValidator<TUser>, PasswordValidator<TUser>>();
  services.TryAddScoped<IPasswordHasher<TUser>, PasswordHasher<TUser>>();
  services.TryAddScoped<ILookupNormalizer, UpperInvariantLookupNormalizer>();
  services.TryAddScoped<IRoleValidator<TRole>, RoleValidator<TRole>>();
  services.TryAddScoped<IdentityErrorDescriber>();
  services.TryAddScoped<ISecurityStampValidator, SecurityStampValidator<TUser>>();
  services.TryAddScoped<IUserClaimsPrincipalFactory<TUser>, UserClaimsPrincipalFactory<TUser, TRole>>();
  services.TryAddScoped<UserManager<TUser>, AspNetUserManager<TUser>>();
  services.TryAddScoped<SignInManager<TUser>, SignInManager<TUser>>();
  services.TryAddScoped<RoleManager<TRole>, AspNetRoleManager<TRole>>();
  if (setupAction != null)
    services.Configure<IdentityOptions>(setupAction);
  return new IdentityBuilder(typeof (TUser), typeof (TRole), services);
}

Does anyone know how I can resolve my UserManager? Any help would be appreciated.


Solution

  • All you're doing here is testing the code you've written to test your code. And, even then, the code you're ultimately hoping to test is framework code, which you shouldn't be testing in the first place. Identity is covered by an extensive test suite. You can safely assume that a method like FindByNameAsync works. This is all a huge waste of time and effort.

    To truly integration test, you should use the TestServer to hit something like a Register action. Then, you assert that the user "posted" to that action actually ended up in the database. Throw all this other useless code out.