Using custom startup class with ASP.NET Core integration tests

My previous post demonstrated how to use custom appsettings.js file with integration tests in ASP.NET Core. But in practice it’s not enough and very often we need custom startup class that extends the one in web application project to configure application for integration tests. This blog post shows how to do it.

NB! Demo solution with unit and integration tests covering all my ASP.NET Core testing posts is available at my GitHub repository gpeipman/AspNetCoreTests.

Getting started

Using custom startup class is a little bit tricky. I start again with simple classic integration test from ASP.NET Core integration testing document.

public class HomeControllerTests : IClassFixture<WebApplicationFactory<Startup>>
{
    private readonly WebApplicationFactory<Startup> _factory;

    public HomeControllerTests(WebApplicationFactory<Startup> factory)
    {
        _factory = factory;
    }

    [Theory]
    [InlineData("/")]
    public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync(url);

        // Assert
        response.EnsureSuccessStatusCode(); // Status Code 200-299
        Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
    }
}

Making members of Startup class virtual

To avoid all kind of confusion with method calls we have to make the methods in web application startup class virtual.

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public virtual void ConfigureServices(IServiceCollection services)
    {
        // Configure services
        // Configure dependency injection
    }

    public virtual void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
    {
        // Configure application
    }
}

If there’s only one method to override then other methods in Startup class can be non-virtual.

Adding custom startup class

Now let’s create custom startup class to integration tests project. I call it FakeStartup. It extends Startup class of web application and overrides Configure() method.

public class FakeStartup : Startup
{
    public FakeStartup(IConfiguration configuration) : base(configuration)
    {
    }

    public override void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
    {
        base.Configure(app, env, loggerFactory);

        var serviceScopeFactory = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>();
        using (var serviceScope = serviceScopeFactory.CreateScope())
        {
            var dbContext = serviceScope.ServiceProvider.GetService<ApplicationDbContext>();

            if (dbContext.Database.GetDbConnection().ConnectionString.ToLower().Contains("database.windows.net"))
            {
                throw new Exception("LIVE SETTINGS IN TESTS!");
            }

            // Initialize database
        }
    }
}

I didn’t add much logic to Configure() method of FakeStartup class. There’s just one check to make sure that SQL Server connection string doesn’t point to live database. And there’s also room left for database initialization.

Custom web application factory

To make test host use our fake startup class with web application we need to apply some magic. First we need custom web application factory that provides integration tests mechanism with custom web host builder. Here is my application factory – dummy and very general.

public class MediaGalleryFactory<TEntryPoint> : WebApplicationFactory<TEntryPoint> where TEntryPoint : class
{
    protected override IWebHostBuilder CreateWebHostBuilder()
    {
        return WebHost.CreateDefaultBuilder(null)
                      .UseStartup<TEntryPoint>();
    }
}

NB! Configuration built for web host in method above must contain same services as specified in program.cs file of web application. To avoid synchronizing changes between web host builders in web application and integration tests project use some general method for web host configuring or move configuration to Startup class.

Configuring integration test

Using this custom web application factory doesn’t come for free. We have to solve some issues when switching over to fake startup class:

  1. Custom location for web host builder confuses integration tests mechanism and we have to point out the correct location of web application content root (this is the folder of web application).
  2. Related to previous point we have to tell that web application assembly is the one where application parts like controllers, views etc must be searched.

Simple integration test in the beginning of this post is here with support for custom startup class.

public class HomeControllerTests : IClassFixture<MediaGalleryFactory<FakeStartup>>
{
    private readonly WebApplicationFactory<FakeStartup> _factory;

    public HomeControllerTests(MediaGalleryFactory<FakeStartup> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.UseSolutionRelativeContentRoot("MediaGallery");

            builder.ConfigureTestServices(services =>
            {
                services.AddMvc().AddApplicationPart(typeof(Startup).Assembly);
            });
               
        });
    }

    [Theory]
    [InlineData("/")]
    public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync(url);

        // Assert
        response.EnsureSuccessStatusCode(); // Status Code 200-299
        Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
    }
}

Now we are done with custom startup class but let’s stilll get further.

Using custom appsettings.js for integration tests

When I run this test I get the following error. Remember what we did in custom startup class?

Integration test failed because of live settings

To solve this issue we have to go back to my previous blog post Using custom appsettings.json with ASP.NET Core integration tests. We need appsettings.json file where database connection string and other settings are defined for integration tests.

After introducing appsettings.json for integration tests our test class is complete.

public class HomeControllerTests : IClassFixture<MediaGalleryFactory<FakeStartup>>
{
    private readonly WebApplicationFactory<FakeStartup> _factory;

    public HomeControllerTests(MediaGalleryFactory<FakeStartup> factory)
    {
        var projectDir = Directory.GetCurrentDirectory();
        var configPath = Path.Combine(projectDir, "appsettings.json");

        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.UseSolutionRelativeContentRoot("MediaGallery");

            builder.ConfigureAppConfiguration(conf =>
            {
                conf.AddJsonFile(configPath);
            });

            builder.ConfigureTestServices(services =>
            {
                services.AddMvc().AddApplicationPart(typeof(Startup).Assembly);
            });               
        });
    }

    [Theory]
    [InlineData("/")]
    public async Task Get_EndpointsReturnSuccessAndCorrectContentType(string url)
    {
        // Arrange
        var client = _factory.CreateClient();

        // Act
        var response = await client.GetAsync(url);

        // Assert
        response.EnsureSuccessStatusCode(); // Status Code 200-299
        Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType.ToString());
    }
}

Wrapping up

Custom startup class for integration tests may be useful when integration tests need additional configuring and we need to do it when web application is configuring. My simple custom startup class demonstrated how to fail tests when integration tests are using live configuration. It’s also possible to use custom startup class to configure services using integration tests specific options. Getting custom startup class work was challenging but it’s actually easy once it’s done as the amount of new code was small.

Gunnar Peipman

Gunnar Peipman is ASP.NET, Azure and SharePoint fan, Estonian Microsoft user group leader, blogger, conference speaker, teacher, and tech maniac. Since 2008 he is Microsoft MVP specialized on ASP.NET.

    14 thoughts on “Using custom startup class with ASP.NET Core integration tests

    • February 5, 2020 at 5:56 pm
      Permalink

      Very useful, thanks a lot!

    • March 19, 2020 at 12:04 pm
      Permalink

      Very informative post,

      does CreateWebHostBuilder should be now CreateHostBuilder? How does this blog post gets affected if i am using .net core 3.1 ?

      Thank you

    • March 20, 2020 at 10:33 am
      Permalink

      Thank you. In the sample I can not see an example of using in memory DB provider using 3.1 for integration tests. I am unable to override the default live DB behavior with in memory DB provider. Do you have any suggestions regarding the same ? I am thinking perhaps i should change the main startup with a separate method with db context registration and then override in integration tests.

      If however, there is any other option without changing the main app startup , i would love to know about it.

      Great post and it saved a lot oftime :)

      THank you

    • March 20, 2020 at 11:09 am
      Permalink

      Here are integration tests: https://github.com/gpeipman/AspNetCoreTests/tree/master/AspNetCoreTests/AspNetCoreTests.IntegrationTests Integration tests should not use in-memory database but real database. Same type of database that your system will use in live. The idea of integration tests is to make sure that application works with external dependencies like expected.

      If you like to override connection string and make nothing in startup class then check out how to use custom appsettings.json file for integration tests: https://gunnarpeipman.com/aspnet-core-integration-tests-appsettings/

    • March 20, 2020 at 11:31 am
      Permalink

      Thank you, yes I understand that. However, I am running into synchronization issues while calls to EnsureDeleted or EnsureCreated happens in the startup class. I am getting Postgres Exceptions such as __EFMigrationsHistory already exists OR postgres tuple concurrently updated.

      So my team thinks that in order to not be blocked, for now put in the in memory DB tests.

    • March 20, 2020 at 12:49 pm
      Permalink

      Make sure you have CollectionAttribute on tests like I have. This guarantees that tests are run sequentially and not in parallel.

      [Collection(“Sequential”)]
      public class CustomerControllerTests : TestBase
      {
      // tests
      }

    • March 20, 2020 at 1:03 pm
      Permalink

      wow .. cool thanks, now it works. but database exists after the tests are done ? What would be the best way to delete the database once tests are done.

    • March 20, 2020 at 1:27 pm
      Permalink

      One option is to make tests implement IDisposable interface and delete database when test class is disposed. It works because for every test new instance of test class is created. Usually I don’t bother much about database left by last test as I run tests on CI server and next step after integration tests is always test database delete if it exists.

    • March 20, 2020 at 1:52 pm
      Permalink

      OK, i will try that way only, earlier I did not know this sequential access that is why i was having synch issues while deleting them in dispose methods.
      Out of curiosity, can you suggest me if my approach to use in memory DB provider would be correct, which is to override the db registration part in startup -> teststartup !!

      Thank you

    • March 20, 2020 at 2:13 pm
      Permalink

      If you are using different database in live then integration tests doesn’t make any sense. In-memory provider is just an objects collection held in memory and it by behavior it is different than real database. It rules in-memory database practically out. Use it only for getting integration tests run.

      If you are using same type of database for tests as for live then just use appsettings.json in integration tests project like I referred above. If you need to use different type of database then you should extend startup class and override database registration.

    • March 21, 2020 at 8:50 am
      Permalink

      Thank you for your valuable inputs. I have two more questions though, in integration tests project you are using UseSolutionRelativeContentRoot like you mentioned in the blog.
      1. In my test project tests are running fine without this call as well, Is this call require when running tests in a different environment e.g. CI pipeline. Because in my local environment they are running fine without this call as well.
      2. The method requires the relative path of the SUT project root. So what is the best practice in this case for example, I mean is it better to get the relative path at run time , instead of passing the web project relative path something like MyAspNetCorProj.Web ?

      Thanks
      Kuldeep

    • March 21, 2020 at 9:35 am
      Permalink

      1. In my environment UseSolutionRelativeContentRoot() was necessary as otherwise views etc were not found. I left this call in after moving from 3.0 to 3.1. If you can run tests without this call then just comment it out. As soon as you need this call you can uncomment it.
      2. This is the point were I didn’t found any good solution to skip using hardcoded string. Okay, it’s possible to use nameof() operator but it is still string constant after compiling. It’s possible to write some code to detect the path automagically but again – what happens if there are multiple web applications in solution? I usually build public and admin parts of applications as two separate applications so it’s easy to host admin part somewhere else.

    Leave a Reply

    Your email address will not be published. Required fields are marked *