TL;DR: Here's my current implementation in a gist.
Based loosely on Microsoft's WebApplicationFactory
, I came up with this. I'm sure there are scenarios it doesn't handle and there is plenty of room for improvement, but it gets me what I wanted: a worker service running in-process just like with WebApplicationFactory
. This is using the HostApplicationBuilder
, while you're using IHostBuilder
. However, the code is easily adaptable to IHostBuilder
if you want to continue using it. The gist linked above does handle IHostBuilder
properly.
Create a discoverable method to hook into
Microsoft's testing framework looks for methods named BuildWebHost
, CreateWebHostBuilder
, and CreateHostBuilder
on the assembly entrypoint. The framework will invoke these methods to get what they need to create an in-process host. However, in a worker service our entry point is Program.cs
and the only declared method is <Main>$
. So we'll need to give ourselves a method to retrieve a builder. We'll also declare Program
as public
; that's important later on.
Our Program.cs
becomes
var builder = CreateHostBuilder(new HostApplicationBuilderSettings { Args = args });
// If there's any builder setup you don't want in the tests, such as auth, do it here.
var host = builder.Build();
host.Run();
public sealed partial class Program
{
private static HostApplicationBuilder CreateHostBuilder(HostApplicationBuilderSettings settings)
{
var builder = Host.CreateApplicationBuilder(settings);
// The rest of your setup. builder.Services.AddStuff(), etc.
// Any builder setup you want to make it into the tests
// MUST be done in here.
...
}
}
Create a factory to spin up a host
Now, we need a factory which will take a generic parameter, create our builder, and start up a host. I created a file called WorkerServiceFactory.cs
for this. We'll also want a second class to delay our host startup so that IAsyncLifetime
will work properly with our factory. I've named this TestWorkerService.cs
We'll take it step by step here, with the full code provided below.
NOTE: You could do a fancier factory method lookup which would handle more scenarios. The gist I linked above does exactly that. I've opted to keep it simpler here.
TestWorkerService.cs
All of our work here can be done in the constructor.
We receive an argument with the builder from which to create our host, and an argument which will configure the builder. We then build the host and start it, being sure not to block the thread waiting for the host to complete.
public sealed class TestWorkerService : IDisposable
{
internal readonly IHost Host;
internal TestWorkerService(
HostApplicationBuilder builder,
Action<HostApplicationBuilder> configure
)
{
configure(builder);
Host = builder.Build();
Host.StartAsync().GetAwaiter().GetResult();
}
public void Dispose()
{
Host.Dispose();
}
}
WorkerServiceFactory.cs
First, we need to discover our application's entrypoint.
var entryPoint = typeof(T).Assembly.EntryPoint!.DeclaringType!;
Then, we'll find the method we created earlier in our Program.cs
. I'm using a property (provided below) for the method name so that you could change it if you wanted to. You'd just need to change the method name in your Program.cs
, then override the CreateBuilderMethodName
property.
var hostBuilderFactory = entryPoint.GetMethod(
CreateBuilderMethodName,
BindingFlags.Static | BindingFlags.NonPublic
)!;
We'll construct the settings to pass to the builder, setting the environment name to a variable named Environment
(provided below). Then, we invoke the factory method with these settings and cast the return type to HostApplicationBuilder
.
var settings = new HostApplicationBuilderSettings
{
ApplicationName = entryPoint.Assembly.GetName().Name,
EnvironmentName = Environment
};
_builder = (HostApplicationBuilder)hostBuilderFactory.Invoke(null, new object?[] { settings })!;
Now, we'll create a field and property to hold our TestWorkerService
. Waiting to initialize _workerService
until it's accessed will prevent our TestWorkerService
from starting up our host until after InitializeAsync()
, if we're using IAsyncLifetime
. This allows, for example, a Docker container to be started and accessible when we call ConfigureHost
.
protected virtual void ConfigureHost(HostApplicationBuilder builder) { }
private TestWorkerService? _workerService;
public TestWorkerService WorkerService =>
_workerService ??= new TestWorkerService(_builder, ConfigureHost);
Here's the full code:
public abstract class WorkerServiceFactory<T> : IDisposable
{
private readonly HostApplicationBuilder _builder;
protected WorkerServiceFactory()
{
var entryPoint = typeof(T).Assembly.EntryPoint!.DeclaringType!;
var hostBuilderFactory = entryPoint.GetMethod(
CreateBuilderMethodName,
BindingFlags.Static | BindingFlags.NonPublic
)!;
var settings = new HostApplicationBuilderSettings
{
ApplicationName = entryPoint.Assembly.GetName().Name,
EnvironmentName = Environment
};
_builder = (HostApplicationBuilder)
hostBuilderFactory.Invoke(null, new object?[] { settings })!;
}
protected virtual void ConfigureHost(HostApplicationBuilder builder) { }
public void Dispose()
{
_workerService?.Dispose();
ServiceScope.Dispose();
GC.SuppressFinalize(this);
}
public void Start()
{
_ = WorkerService;
}
protected virtual string CreateBuilderMethodName { get; } = "CreateHostBuilder";
protected virtual string Environment { get; } = Environments.Development;
public IServiceProvider Services => WorkerService.Host.Services;
private TestWorkerService? _workerService;
public TestWorkerService WorkerService =>
_workerService ??= new TestWorkerService(_builder, ConfigureHost);
// Optional: creates a service scope for you.
/* public TService GetService<TService>()
where TService : class => ServiceScope.ServiceProvider.GetRequiredService<TService>();
private IServiceScope? _serviceScope;
private IServiceScope ServiceScope => _serviceScope ??= Services.CreateScope();*/
}
Create a derived factory and perform any final configuration
All that's left to do is to create the factory you want to use in your tests. We'll inherit from our WorkerServiceFactory
for this, passing our (now public) Program
as the assembly marker.
public sealed class MyWorkerFactory : WorkerServiceFactory<Program>
{
protected override void ConfigureHost(HostApplicationBuilder builder)
{
// Configure your test services via builder.Services.
// Configure your config via builder.Configuration.
}
}
You should now be able to use this just like you would the WebApplicationFactory
, minus some more advanced features which you could add if you want.
var sut = new WebApplicationFactory<Program>().Services.GetRequiredService<Worker>()
? โ Kessler