A Different Approach To Test Your ASP.NET Core Application


Isn't it obvious how precious automated tests are for our applications? Nevertheless, integrating automated testing in a development process is far from obvious! In this article I will present you a different approach we have experienced to easily write tests for ASP.NET Core applications. That said, you may leverage those principles in a different technical context.

Introduction

Let's start with the beginning:

You are working on a pretty big application and you took some time to think and design the architecture trying to comply to some notorious development guidelines. You did it because separating the concerns makes each layer easier to maintain and easier to test.

Indeed, the purpose of a unit test is to validate the behavior of a specific piece of code. This brings you the following marvelous thing: if a test fails, you'll know exactly what is going wrong, you know it early and through an automated mean, allowing you to prevent unwanted behaviors.

Nevertheless, there is a dark side. To do that, you must isolate each layer from its dependencies to only cover the code you want to test. It means stubbing the dependencies and also maintaining your stubs (or mocks). Moreover, in this case your tests are very dependent of the implementation details so if a detail has changed, you are likely to have to update the tests as well. Doing so is costly and might also lead to new defects.

Despite the tons of the excellent available tooling, bootstratping tests, creating stubs, maintaining them takes time. This has a terrible consequence:

As your project may run out of time (unexpected technical issues, communication problems, etc.) and out of cash, you will have to make some choices and review priorities.

And, yes, despite knowing this is a bad idea, you'll stop writing tests... After all, You don't need to write automated tests to prepare your sprint demo in a rush.

We all know the other main and bad consequence, if we don't write tests as we code, we rarely come back to do so.

What about a different approach ?

As we did, you are likely to experience the following scenario. With some recent projects, the context imposed to cover at least 75% of the code whatever happens! So we decided to take a look at a different concept: Integration Testing

What is an integration test ?

An integration test is a test which validate that a solution and its components when assembled works as expected

It means that if your solution depends on a SMTP server to send emails, you have to configure a test environment with a test SMTP server in order to test your solution. It could totally make sense but it sounded like a bit overpowered given our needs. So, we took a different approach again : Behavior Testing

What is a behavior test ?

A behavior test is a test which validates a scenario in general and which does not rely on implementation details.

One could relate this to the Behavior Driven Development concept yet, it's not the purpose of this post. Shortly, the aim is to write the minimum amount of code to test the maximum amount of functional code related to a specific scenario. This is exactly what we wanted :D.

Our tests will cover a specific scenarios from end to end and in a single shot without interference of external dependencies (email server, database, etc.) which are less relevant to our testing objectives.

Enough talking, How to get this done ?

Let's go through some steps to implement behavior testing with ASP.NET Core.

First, we have to analyze the way our application is bootstrapped and configured. It is very important because it's where you can put some circuit breaker logic for testing purposes.

An ASP.NET Core application uses the WebHostBuilder class to add some hosting environment configuration such as startup configuration. It does so by passing a Startup object to the builder.

This startup class must define 2 methods, ConfigureServices and Configure, to be accepted as a startup configuration object for ASP.NET Core (see https://docs.microsoft.com/en-us/aspnet/core/fundamentals/startup for more information about Asp.Net Core Startup).

This is the perfect entry point for our test environment configuration. We will configure the builder to inject a startup configuration service that will be used by the Startup object as a circuit breaker to configure the dependencies for the 'Test' context or not.

Startup Configuration Service

Let's define an interface to configure our application that allows a default and a test implementation :

public interface IStartupConfigurationService
{
    void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory);

    void ConfigureEnvironment(IHostingEnvironment env);

    void ConfigureService(IServiceCollection services, IConfigurationRoot configuration);
}

This interface exposes a Configure and a ConfigureService method like the Startup object and also a ConfigureEnvironment method to allow pre-configure configuration ;)

Startup Configuration

You can now define your own IStartupConfigurationService implementation meeting your requirements. Let's define a test implementation which will configure a database stub using Entity Framework Core with SQLite:

public class TestStartupConfigurationService<TDbContext> : IStartupConfigurationService
    where TDbContext : DbContext
{
    public virtual void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
    {
        SetupStore(app);
    }

    public virtual void ConfigureEnvironment(IHostingEnvironment env)
    {
        env.EnvironmentName = "Test";
    }

    public virtual void ConfigureService(IServiceCollection services, IConfigurationRoot configuration)
    {
        ConfigureStore(services);
    }

    protected virtual void SetupStore(IApplicationBuilder app)
    {
        using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
        {
            var dbContext = serviceScope.ServiceProvider.GetService<TDbContext>();

            dbContext.Database.OpenConnection();
            dbContext.Database.EnsureCreated();
        }
    }

    protected virtual void ConfigureStore(IServiceCollection services)
    {
        var connectionStringBuilder = new SqliteConnectionStringBuilder { DataSource = ":memory:" };
        var connectionString = connectionStringBuilder.ToString();
        var connection = new SqliteConnection(connectionString);

        services.AddDbContext<TDbContext>(options => options.UseSqlite(connection));
    }
}

In this example, we configured Entity Framework Core for a specific DbContext to use SQLite provider using its in memory mode. We also set a clear environment name so there won't be any configuration conflicts.

Now let's go back to our ASP.NET Core application. We have implemented a configuration service for testing purpose but we also need an implementation for the Real World case. If we work with Entity Framework Core for database access, it could look like this:

public class StartupConfigurationService : IStartupConfigurationService
{
    public virtual void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) { }

    public virtual void ConfigureEnvironment(IHostingEnvironment env) { }

    public virtual void ConfigureService(IServiceCollection services, IConfigurationRoot configuration)
    {          
        services.AddDbContext<[YOUR_CONTEXT_TYPE]>(options =>
               options.UseSqlServer(@"[SQL_CONNECTION_STRING]"));
    }
}

We now have a configuration that allows a connection to an existing Sql Server database for normal case and a test configuration to work with an in-memory SQLite database for test case.

Application Configuration

Let's inject our configuration service inside the Startup class. To do so, we are going to use the WebHostBuilder because it is in charge of the Startup instantiation. The entry point of our Asp.Net Core application is represented by the Program class in Program.cs file, it should contain something like this:

var webHostBuilder = new WebHostBuilder();
webHostBuilder.UseKestrel()
              .UseContentRoot(Directory.GetCurrentDirectory())
              .UseIISIntegration()
              .UseStartup<Startup>()
              .Build()
              .Run();

Register your configuration service within the builder and before the call to the Build method:

webHostBuilder.ConfigureServices(s => s.AddSingleton<IStartupConfigurationService, StartupConfigurationService>());

Then, you can inject the configuration service in your Startup class and call the configuration methods from their counterpart:

public class Startup
{
    private IStartupConfigurationService externalStartupConfiguration;

    public Startup(IHostingEnvironment env, IStartupConfigurationService externalStartupConfiguration)
    {
        this.externalStartupConfiguration = externalStartupConfiguration;
        this.externalStartupConfiguration.ConfigureEnvironment(env);
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();

        // Pass configuration (IConfigurationRoot) to the configuration service if needed
        this.externalStartupConfiguration.ConfigureService(services, null);
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
    {
        this.externalStartupConfiguration.Configure(app, env, loggerFactory);

        using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
        {
            serviceScope.ServiceProvider.GetService<[DBCONTEXT_TYPE]>().Database.EnsureCreated();
        }

        app.UseMvc();
    }
}

Test Your Scenario

One last step to reach our goal. Let's write a test for our scenario!

We will use a specific WebHostBuilder for our test. It will be configured with the TestStartupConfigurationService and will be instantiate by an in-memory pipeline thanks to the TestServer class (for more information about the TestServer, see the documentation about Integration Testing with ASP.NET Core: https://docs.microsoft.com/en-us/aspnet/core/testing/integration-testing).
The TestServer class provides an entry point to the web host through a configured HttpClient.

Now our test scenario could look like this: :)

[TestMethod]
public void TestScenario()
{
    Data[] data = CreateTestScenarioDataIntoInMemoryDb();

    var webHostBuilder = new WebHostBuilder();
    webHostBuilder.ConfigureServices(
        s => s.AddSingleton<IStartupConfigurationService, TestStartupConfigurationService<[DBCONTEXT_TYPE]>>());
    webHostBuilder.UseStartup<Startup>();
    var testServer = new TestServer(webHostBuilder);

    var response = testServer.CreateClient().GetAsync("/api/data").Result;
    response.EnsureSuccessStatusCode();

    var result = response.Content.ReadAsAsync<Data[]>().Result;

    Assert.AreEqual(data.Length, result.Length);
    for (int i = 0; i < data.Length; i++)
    {
        Assert.AreEqual(data[i], result[i]);
    }
}

Here it is ! You now have a test which crosses each of your application layers (from the front web layer to the data access layer) and covers a specific scenario from end to end. All of that in a few lines of code :).

The only part that can not be covered is the UI part but this is the responsibility of UI tests which is a completely different topic ;)

Just a Start

Once the configuration fulfilled your needs, writing behavior tests for your scenarios is as simple as child's play...really...we are using them every day even during rush phases and it has already saved us a huge amount of time.

It was just a presentation of how you can write your tests differently, I'll be back with more information, reflection and samples on specific subjects like how to handle authentication or Controller testing.

So stay tuned, big things are coming... :D !

Update : Discover our new posts about testing apps.

Arnaud

Arnaud

I am a Developer/Tech-Lead freelance. Mainly focused on Mobile, Web and Cloud development, I'm simply a technology enthusiast who love learn and share new stuff.

Read More