Quick fix for integration testing with Selenium in ASP.NET Core 3.1

Quick fix for integration testing with Selenium in ASP.NET Core 3.1

For those who read Scott Hanselman about integration testing, you’ll have an issue while migrating to ASP.NET Core 3. You’ll end up with an error as the URI will be empty.

In this short post, I’ll show you how to fix it in a few lines!

Disclaimer: I’m still not very keen about the complexity of the original solution but I don’t think it has been improved in .NET Core so it’s a small trade-off to be able to do real integration testing!

At some point, you have created a class that inherits from WebApplicationFactory. Problem is, with .NET Core 3, the method CreateServer is not called, so the original workaround doesn’t work anymore.

The fix is to make an explicit call to CreateServer method with a new method to be added to the class. See the example below.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;

namespace Withywoods.WebTesting.TestHost
{
    public class LocalServerFactory<TStartup> : WebApplicationFactory<TStartup>
        where TStartup : class
    {
        private const string _LocalhostBaseAddress = "https://localhost";

        private IWebHost _host;

        public LocalServerFactory()
        {
            ClientOptions.BaseAddress = new Uri(_LocalhostBaseAddress);

            // Breaking change while migrating from 2.2 to 3.1, TestServer was not called anymore
            CreateServer(CreateWebHostBuilder());
        }

        public string RootUri { get; private set; }

        protected override TestServer CreateServer(IWebHostBuilder builder)
        {
            _host = builder.Build();
            _host.Start();
            RootUri = _host.ServerFeatures.Get<IServerAddressesFeature>().Addresses.LastOrDefault();

            // not used but needed in the CreateServer method logic
            return new TestServer(new WebHostBuilder().UseStartup<TStartup>());
        }

        protected override IWebHostBuilder CreateWebHostBuilder()
        {
            var builder = WebHost.CreateDefaultBuilder(Array.Empty<string>());
            builder.UseStartup<TStartup>();
            return builder;
        }

        [ExcludeFromCodeCoverage]
        protected override void Dispose(bool disposing)
        {
            base.Dispose(disposing);
            if (disposing)
            {
                _host?.Dispose();
            }
        }
    }
}

And here is a simple example of a actual test class.

using System;
using System.Net.Http;
using FluentAssertions;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Remote;
using Withywoods.Selenium;
using Withywoods.WebTesting.TestHost;
using Xunit;

namespace Withywoods.AspNetCoreApiSample.IntegrationTests.Resources
{
    [Trait("Environment", "Localhost")]
    public class SwaggerResourceTest : IClassFixture<LocalServerFactory<Startup>>, IDisposable
    {
        private const string _ResourceEndpoint = "swagger";
        private const string _ChromeDriverEnvironmentVariableName = "ChromeWebDriver";

        private readonly HttpClient _httpClient;
        private readonly RemoteWebDriver _webDriver;
        private readonly LocalServerFactory<Startup> _server;

        public SwaggerResourceTest(LocalServerFactory<Startup> server)
        {
            _server = server;
            _httpClient = server.CreateClient();

            var chromeOptions = new ChromeOptions();
            // if there is an issue with the run in CI, comment the headless part
            chromeOptions.AddArguments("--headless", "--ignore-certificate-errors");
            // chrome driver is sensitive to chrome browser version, CI build should provide the path to driver
            // for Azure DevOps it's described here for example: https://github.com/actions/virtual-environments/blob/master/images/win/Windows2019-Readme.md
            var chromeDriverLocation = string.IsNullOrEmpty(Environment.GetEnvironmentVariable(_ChromeDriverEnvironmentVariableName)) ?
                System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) :
                Environment.GetEnvironmentVariable(_ChromeDriverEnvironmentVariableName);
            _webDriver = new ChromeDriver(chromeDriverLocation, chromeOptions);
        }

        [Fact]
        public void AspNetCoreApiSampleSwaggerResourceGet_ReturnsHttpOk()
        {
            _server.RootUri.Should().Be("https://localhost:5001");

            try
            {
                // Arrange & Act
                _webDriver.Navigate().GoToUrl($"{_server.RootUri}/{_ResourceEndpoint}");

                // Assert
                _webDriver.FindElement(By.ClassName("title"), 60);
                _webDriver.Title.Should().Be("Swagger UI");
                _webDriver.FindElementByClassName("title").Text.Should().Contain("My API");
            }
            catch
            {
                var screenshot = (_webDriver as ITakesScreenshot).GetScreenshot();
                screenshot.SaveAsFile("screenshot.png");
                throw;
            }
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                _webDriver?.Dispose();
            }
        }
    }
}

Source code is available in GitHub: devpro/withywoods and you can look at the CI Azure pipeline that runs every night: withywoods-CI.

Don’t hesitate to contact me directly (on twitter) if you have any question or leave a comment on this page!

References:
Integration tests in ASP.NET Core
Real Browser Integration Testing with Selenium Standalone, Chrome, and ASP.NET Core 2.1

bertrand

Leave a Reply

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