X

Unit testing multi-tenant database provider

My saga on supporting multiple tenants in ASP.NET Core web applications has come to point where tenants can use separate databases. It’s time now to write some tests for data context to make sure it behaves correct in unexpected situations. This post covers unit tests for data context and multi-tenancy.

Database context

To make reading easier I give here simplified version data context I used in previous posts.

public class PlaylistContext : DbContext
{
    private readonly Tenant _tenant;

    public DbSet<Playlist> Playlists { get; set; }
    public DbSet<Song> Songs { get; set; }

    public PlaylistContext(DbContextOptions<PlaylistContext> options,
                            ITenantProvider tenantProvider)
        : base(options)
    {
        _tenant = tenantProvider.GetTenant();
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseSqlServer(_tenant.DatabaseConnectionString);

        base.OnConfiguring(optionsBuilder);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Playlist>().HasKey(e => e.Id);
        modelBuilder.Entity<Song>().HasKey(e => e.Id);

        base.OnModelCreating(modelBuilder);
    }
}

As every tenant has separate database there is no need to force tenant ID through global query filters automatically to all domain entities.

What to test?

Databse context below uses tenant provider to detect connection string for database the tenant must use. As there is dependency to tenant provider it is important to write some tests to be sure that data context class behaves like expected. So, what can possibly go wrong?

Well, there are few things to take care with unit tests:

  • tenant provider is null,
  • tenant is null,
  • database connection string is missing.

Actually the last thing should not happen but never say never. It’s possible that tenants file on cloud is serialized by list of custom constructured DTO-s or modified manually and therefore test for missing connection string is justified.

Adding checks

Before writing tests let’s add checks for invalid data to data context class. The code below covers all three checks given above.

public class PlaylistContext : DbContext
{
    private readonly Tenant _tenant;

    public DbSet<Playlist> Playlists { get; set; }
    public DbSet<Song> Songs { get; set; }

    public PlaylistContext(DbContextOptions<PlaylistContext> options,
                            ITenantProvider tenantProvider)
        : base(options)
    {

        if(tenantProvider == null)
        {
            throw new ArgumentNullException(nameof(tenantProvider));
        }

        _tenant = tenantProvider.GetTenant();

        if(_tenant == null)
        {
            throw new NullReferenceException("Tenant is null");
        }
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if(string.IsNullOrEmpty(_tenant.DatabaseConnectionString))
        {
            throw new NullReferenceException("Connection string is missing");
        }

        optionsBuilder.UseSqlServer(_tenant.DatabaseConnectionString);

        base.OnConfiguring(optionsBuilder);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Playlist>().HasKey(e => e.Id);
        modelBuilder.Entity<Song>().HasKey(e => e.Id);

        base.OnModelCreating(modelBuilder);
    }
}

Writing tests

To test data context some additional classes are needed in test project. Integrations with other parts of system never should happen in unit tests because otherwise they are integration tests. There is dependency to ITenantProvider interface and therefore fake tenant provider is needed for unit tests.

public class FakeTenantProvider : ITenantProvider
{
    private Tenant _tenant;

    public FakeTenantProvider(Tenant tenant)
    {
        _tenant = tenant;         
    }

    public Tenant GetTenant()
    {
        return _tenant;
    }
}

There is one more problem – ITenantProvider is used in protected method OnConfiguring(). This method can be called by external caller only with reflection. The other option is to extend original data context and add new instance of OnConfiguring() method that calles protected version from base class.

public class FakePlaylistContext : PlaylistContext
{
    public FakePlaylistContext(DbContextOptions<PlaylistContext> options, ITenantProvider tenantProvider)
        : base(options, tenantProvider)
    {
    }

    public new void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder);
    }
}

Using these two classes it is possible now to write tests for data context.

[TestClass]
public class PlaylistContextTests
{
    [TestMethod]
    [ExpectedException(typeof(ArgumentNullException))]
    public void ThrowsExpetionIfTenantProviderIsNull()
    {
        var options = new DbContextOptions<PlaylistContext>();
        new PlaylistContext(options, null);
    }

    [TestMethod]
    [ExpectedException(typeof(NullReferenceException))]
    public void ThrowsExceptionIfTenantIsNull()
    {
        var options = new DbContextOptions<PlaylistContext>();
        var provider = new FakeTenantProvider(null);
        new PlaylistContext(options, provider);
    }

    [TestMethod]
    [ExpectedException(typeof(NullReferenceException))]
    public void ThrowsExceptionIfConnectionStringIsMissing()
    {
        var options = new DbContextOptions<PlaylistContext>();
        var tenant = new Tenant { Id = 1 };
        var provider = new FakeTenantProvider(tenant);
        var builder = new DbContextOptionsBuilder();

        var context = new FakePlaylistContext(options, provider);

        context.OnConfiguring(builder);
    }
}

These tests use Microsoft testing stuff that works also on other platforms with ASP.NET Core projects. Running these tests in Visual Studio gives the following result.

All tests passed and mission for this time is successfully completed.

Wrapping up

Tenant providers injected to main Entity Framework Core data context introduce situations where testing is needed. And to test the context one fake class and inherited version of data context were needed. As there are no complex object graph to mock the tests were short and easy. But these tests are necessary to make sure that data context handles problematic situations.

Liked this post? Empower your friends by sharing it!

View Comments (0)

Related Post