Host and lifecycle
What it is
ProtoHost is built once per test process by your runner's assembly setup. It owns the dependency injection container, runs suite-wide hooks and run gates, starts infrastructure, and starts and completes each test.
ProtoExecutionContext exists for exactly one test. It holds that test's clients, typed state, resources, attachments and observations. See Execution context.
1 hooks → 2 attributes → 3 body → 4 attributes back → 5 hooks back → 6 publish → 7 dispose
Hooks run before all attributes. A setup failure rolls back only what completed.
How it works
Building the host
The runner's setup class receives an IProtoHostBuilder:
protected override void Configure(IProtoHostBuilder builder) =>
builder
.ConfigureAppConfiguration(configuration => configuration.AddJsonFile("appsettings.Test.json", optional: true))
.ConfigureServices(services => services.AddSingleton<IClock, FixedClock>())
.ConfigureTracing(trace => trace.OutputPath = "TestResults/run.prototrace")
.ConfigureTestIds(ids => ids.RunPrefix = 42)
.AddRunHook<StartDependenciesHook>()
.AddTestHook<ResetMailboxHook>()
.AddApplication("Api", app => app.AddRest(rest => rest.AddClient("Api")));
IProtoHostBuilder ConfigureServices(Action<IServiceCollection> configure);
IProtoHostBuilder ConfigureAppConfiguration(Action<IConfigurationBuilder> configure);
IProtoHostBuilder ConfigureTracing(Action<ProtoTraceOptions> configure);
IProtoHostBuilder ConfigureTestIds(Action<ProtoTestIdOptions> configure);
IProtoHostBuilder AddTestHook<THook>() where THook : class, IProtoTestHook;
IProtoHostBuilder AddRunHook<TRunHook>() where TRunHook : class, IProtoRunHook;
IProtoHostBuilder AddRunGate<TGate>() where TGate : class, IProtoRunGate;
IProtoHostBuilder AddRunGate(string name, Func<ProtoRunGateContext, ProtoRunGateResult> evaluate);
IProtoHostBuilder AddResource(IProtoResource resource);
ProtoHost Build();
Every integration's Add... method is an extension on this same builder. A builder can only build once. The option tables for ProtoTestIdOptions and ProtoTraceOptions are in Configuration.
The run
- If a
BeforeRunAsyncthrows, the hooks that already started get theirAfterRunAsyncin reverse, and startup fails. StopAsyncruns everyAfterRunAsynceven if some throw, then reports all failures together.- Once stopping has begun, starting a new test throws.
ProtoTest's own run hooks are ordered to run last on the way out: run gates evaluate first, report sinks export next, run-scoped resources release, and the trace archive is written last, so it can include the reports.
A test
When the runner starts a test:
- A
ProtoExecutionContextand a new DI scope are created, and the context becomesProto.Contextfor this async flow. - Every
IProtoTestHookrunsBeforeTestAsync, in ascendingOrder. ProtoTest's client-initializing hook has the lowest possible order, so clients exist before any of your hooks run. - Every
ProtoAttributeon the test runsBeforeTestAsync, in ascendingOrder. - Your test body runs.
All hooks run before any attribute, whatever their Order values. Among attributes, only Order matters: whether an attribute sits on the class or the method does not change when it runs, and ties keep class attributes first.
When the runner completes the test:
- Attributes run
AfterTestAsyncin reverse order. - Hooks run
AfterTestAsyncin reverse order. - Attachments are published to the runner.
context.DisposeAsyncreleases owned resources in reverse registration order, then disposes the DI scope.- The test's trace artifacts are captured and the recorder is completed with its outcome.
Proto.Contextis cleared.
Teardown attempts each step, even when an earlier one throws. A teardown failure is recorded as an Error finding and does not replace the outcome the test already reported, so a cleanup error never hides a failed assertion. CompleteTestAsync rethrows one failure as-is and aggregates several; the runner adapters complete through ProtoTestScope, which keeps the test's own outcome.
When setup fails
If a hook or attribute throws during setup, ProtoTest rolls back: only the components that completed their BeforeTestAsync get their AfterTestAsync, in reverse. The one that threw does not.
FirstHook:Before
SecondHook:Before
FirstAttribute:Before
FailingAttribute:Before <- throws
FirstAttribute:After
SecondHook:After
FirstHook:After
The test is recorded as failed, the trace shows a Rollback phase instead of Teardown, and the exception reads "Test setup failed and completed lifecycle components were rolled back."
The same reversal in the recording, the sample suite's project journey: setup runs attributes Application, NorthstarTenant, SignedInAs, NorthstarMember, and teardown answers NorthstarMember, SignedInAs, NorthstarTenant, Application.
- Setup500.5 ms
Hooks first in ascending Order, then attributes in ascending Order. The client hook runs before everything else.
Recorded operations (4)
- ProtoClientInitializerHook
- SqlConnectionHook, NorthstarScenarioHook
- ProtoHttpAuthLifecycleHook twice
- Application, NorthstarTenant, SignedInAs, NorthstarMember
- Teardown36.7 ms
The same components walk back: attributes in reverse, then hooks in reverse, then publish and release.
Recorded operations (4)
- NorthstarMember, SignedInAs, NorthstarTenant, Application
- Auth hooks, scenario, SQL, completion, initializer
- Request, response, expected shape, scenario summary
- Tenant cleanup, services, connection, consumer
docs/static/lessons/l1-first-journey.prototrace. The viewer draws the same trace from that archive (download it and drop it on the viewer).This is why teardown code should tolerate partial setup. The sample environment attribute uses TryResolve rather than Resolve in its AfterTestAsync for exactly that reason.
One test per async flow
A context is tied to the async flow that started it. Starting a second test on the same flow before completing the first throws, as does completing a test from a different host. Skipped tests never reach this point: a skip condition is evaluated before StartTestAsync, so there is no context to complete.
How to use it
Runner packages start and complete tests for you; the hand-driven surface lives on Host API.
Test ids
Every test gets a numeric id, and everything the test produces is keyed by it: trace entries, archived artifacts, and often the data your own attributes create ($"test-{context.TestId}").
An id is a run prefix followed by a sequence: with the defaults, 482913000001, 482913000002, and so on.
ProtoTestIdOptions | Default |
|---|---|
RunPrefix | a random six-digit number per host |
SequenceDigits | 6 (1 to 9) |
The random prefix keeps ids from colliding when several test processes create data in the same shared environment. Set a fixed RunPrefix, for example from a CI build number, when you want ids you can trace back to a pipeline run. Ids are at most 18 digits; running out of sequence numbers throws.
To replace the scheme entirely, register your own IProtoTestIdGenerator:
public interface IProtoTestIdGenerator
{
ProtoTestId Next(MethodInfo testMethod);
}
Run gates and resources
AddRunGate registers a check that runs once, after the last test and before the reports are written, so it can see everything the run's collectors produced. ProtoRunGateContext exposes Items, ItemsOfKind, InCategory, ForTarget and WithStatus, plus coverage helpers such as CoverageFor(target). A failed gate throws ProtoRunGateException out of AfterRunAsync. The delegate overload is the quick form:
builder.AddRunGate("no error findings", context => context
.ItemsOfKind(ProtoReportItemKinds.Finding)
.Any(item => item.Status == ProtoReportStatus.Error)
? ProtoRunGateResult.Failed("The run recorded error findings.")
: ProtoRunGateResult.Passed("No error findings were recorded."));
| Result | Meaning |
|---|---|
Passed | the run continues to the reports |
Warning | recorded, but the run continues |
Failed | throws ProtoRunGateException out of AfterRunAsync |
Skipped | recorded; a gate that returns no result is treated as failed |
AddResource(IProtoResource) registers an already-created, run-scoped resource, such as a started container or a connection, that the host owns and releases with the run, without starting anything. AddInfrastructure is the variant that starts with the run and fills settings; see Infrastructure.
What the trace shows
- Each test's record opens with a
test.setupoperation, carries onehook.beforeandhook.afterper hook (with its type and order) and oneattribute.beforeandattribute.afterper attribute, then the test's own operations. - A failed setup shows a
Rollbackphase instead ofTeardown, and only the components that completed appear. - Attachments land on the record of the operation that produced them. Resources appear as entities, and a failed release writes a
resource.releaseoperation. - Run-level pieces are entities: capabilities, infrastructure with its state, and readiness probes. Run gates evaluate report items after the last test and before the reports export.
- The trace archive is written last, after the reports, so a report's coverage is inside the archive.
- A teardown failure is recorded as an
Errorfinding without changing the test's outcome.
Limits
- One context per async flow. Starting a second test on the same flow before completing the first throws, and completing a test from a different host throws.
- One build per builder.
Build()can only run once, and once stopping has begun, starting a new test throws. - A skipped test never reaches the lifecycle. A skipped test never creates a context. See Skip conditions.
- A teardown failure does not replace the outcome. It is recorded as an
Errorfinding while the test's own outcome stands. - Ids cannot grow past 18 digits. Running out of sequence numbers throws.
Proto.Contextis flow-local: work that escapes the test's flow cannot read it. See Execution context and Concurrency.