Background workers
ProtoTest.Hosting runs a background worker inside the test process: it starts with the run, reads the run's settings and stops with the run.
[ProtoTest]
public async Task An_invoice_is_created_for_a_metered_session()
{
var billing = Proto.Context.HostService<BillingWorker.Program, BillingService>("Billing");
// drive or inspect the running worker
}
Run it with dotnet test. A green run prints Passed An_invoice_is_created_for_a_metered_session, and the trace records a worker run entity with the worker's name and program.
What it adds
It runs a worker application - a generic host with IHostedServices, the kind dotnet new worker creates - the way ASP.NET Core runs an API. The suite starts the worker's real entry point once per run, after the infrastructure registered before it, and stops it when the run ends.
Install
dotnet add package ProtoTest.Hosting
Compose
builder
.AddInfrastructure(
"AppStore",
chain => chain
.UseConfigured()
.UseContainer(new PostgresDatabase(...)),
"ConnectionStrings:App")
.AddInfrastructure(
"Broker",
chain => chain
.UseConfigured()
.UseContainer(new RabbitMqBroker(...)),
"ConnectionStrings:Broker")
.AddWorkerHost<BillingWorker.Program>("Billing");
The order is the start order: the worker reads what the infrastructure before it provided, so register containers and other pieces first. AddWorkerHost can be called more than once with different names; registering the same name twice is one worker, while the same name with a different program throws instead of silently dropping the second registration.
The suite runs the worker's own program (Host.CreateApplicationBuilder or Host.CreateDefaultBuilder), with the worker's assembly as its content root and application name - its appsettings.json, environment overloads and logging behave as they do in production. The worker's own Run() never executes; the suite starts and stops its host.
The tasks
The An_invoice_is_created_for_a_metered_session test above is the whole pattern: resolve the running worker's service, drive or inspect it.
Proto.Context.Host<TProgram>() returns the worker's IHost for anything the service lookup cannot answer, and both take a name when several workers host the same program.
Nested under an application
A worker belongs to the application it consumes from: nest it with AddWorkerHost on the
application's builder and it follows the application's provider chain instead of a key of its own (see
Environment resolution):
builder.AddApplication("Api", app => app
.UseConfigured()
.UseInProcess<Api.Program>()
.AddWorkerHost<BillingWorker.Program>("Billing", worker => worker
.UseEnvironment() // the application runs elsewhere: the environment runs its worker too
.UseHost())); // the run hosts the worker's entry point and bridges the test clock
UseEnvironment()is available when the application's winner does not run it in-process (a configured address, a loopback listener or an AppHost resource). The suite starts nothing: starting the worker again would join a staging broker as a second consumer.UseHost()is the fallback and the only provider that bridges the test clock and letsProto.Context.Host<TProgram>()resolve the running host.- Every winning provider declares the
workercapability, so[RequiresWorker<TProgram>]passes in every mode; only a hosted worker declaresclock, so[RequiresTestClock]gates the tests that advance the clock against one. - A worker registered on the host builder (
builder.AddWorkerHost<...>) is not part of an application; its chain defaults toUseHost, so the run hosts its entry point and bridges the test clock.
Configuration
The worker reads, in order of precedence:
- Its own sources -
appsettings.json, environment variables, its own command line. - The suite's configuration -
builder.ConfigureAppConfiguration(...). - The run's settings: connection strings from started infrastructure and values from any settings infrastructure.
- Whatever the suite sets in
AddWorkerHostoptions.
The merged overlay reaches the worker's entry point twice: as --{key}={value} command-line arguments when the run invokes Program.Main, so Host.CreateApplicationBuilder(args) and Host.CreateDefaultBuilder(args) see final-precedence values in Main itself - a connection string read there is the run's - and as an in-memory source applied when the host is built, so an options factory or a hosted service reads the same values. Keys with no value are not passed; Set(key, null) is an empty setting, not a dropped key.
The worker builder must be one of the shapes ProtoTest knows: Host.CreateApplicationBuilder, Host.CreateDefaultBuilder, or WebApplication.CreateBuilder. Any other builder fails loudly naming its type, instead of keeping its own configuration and TimeProvider.
builder.AddWorkerHost<BillingWorker.Program>("Billing", worker =>
worker.Set("Billing:RetryLimit", "3"));
In the trace and coverage
The worker is run-scoped infrastructure: the trace records a worker run entity with its name and program, the run overview lists a worker capability, and a release failure follows the same path as any other run resource.
Register the worker after the pieces it needs: a readiness probe placed before AddWorkerHost waits for the broker or database before the worker starts.
Limits
- In-process only. A suite pointed at a published environment has no worker to start. Guard the tests that need one with
[RequiresWorker<TProgram>](the typed form of[RequiresCapability(ProtoCapabilityKinds.Worker)], checked by the program assembly name). For an orchestrated topology instead of an in-process worker, see Aspire. - A nested worker follows its application, not its own key.
UseEnvironment()decides from the application's winner; two workers with the same name are one lifecycle host-wide, and a nested worker's name must be unique across applications. - A worker is not an application. It has no address and no clients; if a worker image ever exposes a health endpoint, give it its own application target instead.
- One instance per run. The worker is shared by every test in the run, exactly like the in-process application; tests must not assume a fresh worker per test.
- No per-test lifetime.
AddWorkerHosthas noPerTestoption; a worker that must restart between tests is not supported. - A parameterless or argument-ignoring
Maincannot see the overlay beforeBuild(). The run passes the overlay as command-line arguments; an entry point whoseMain()takes no arguments, or that builds its host without passingargs, still receives the values when the host is built (the in-memory overlay), so options factories and hosted services read them, but code between creating the builder and callingBuild()reads only the worker's own sources. A parameterlessMainalso never receives the worker's--contentRoot/--applicationName, so it reads its ownappsettings.jsonfrom the test process's content root. Build the host fromargswhenMainitself reads configuration. - Start does not wait for readiness. The host's
StartAsynccompleting is the only signal. If a worker must wait for a dependency to be ready, register a readiness probe beforeAddWorkerHost, or wait inside its own service. - The worker host runs in the test process. Its background threads, loggers and static state are the test process's; a worker that must be killed hard is not a good fit.
Learn more
- Infrastructure - containers and settings the worker reads.
- REST, Messaging - talking to what the worker consumes and produces.