Write an integration
Your code can add what a package adds. That means a client the context resolves, a hook around the test, or entries in the trace. The sample's correlation layer is a real integration of about fifty lines, and it is the model for this lesson.
- Pick the extension point your feature needs from the map.
- Follow a custom client from its initializer to its context call.
- Write your own kind, name and source into the trace correctly.
- Provisioners and page objects (lesson 2).
- The sample cloned and open in an editor.
The scenario
A company client, a fixture loader, a file assertion: each is an integration, and each should feel native. Native means the test resolves it from the context, the run records it, and the failure message names it.
The sample's scenario layer does exactly that: a custom client, an initializer, a hook, an attachment and two trace events. It is small enough to read in one sitting.
What you want, and what to use
The extending page keeps this map. Read it as a menu, not a course:
| You want to | Use |
|---|---|
| package setup for some tests | a ProtoAttribute |
| run code around every test or the whole run | a hook |
| give tests a new client | a client initializer plus an extension method |
| create data in your system | a data provisioner |
| report on what tests did | observations and a collector |
| write reports somewhere | a sink |
| show up in the trace | the trace writer |
The sample's scenario layer uses three of them at once: a client, its initializer, and a hook that writes the trace and the attachment.
The custom client
The probe is a plain class with a list of milestones. The initializer is what registers it with the context:
1public sealed class ScenarioProbeInitializer : IProtoClientInitializer<ScenarioProbe>2{3public string Name => "ScenarioProbe";45public Task<bool> TryInitializeAsync(ProtoExecutionContext context)6{7context.RegisterClient(new ScenarioProbe(), Name);8return Task.FromResult(true);9}10}
- One initializer per client type
The interface is generic over the client, so the host knows what it is creating.
- The name is the handle
A test resolves the client by this name. Returning false means not me: the next initializer in the group is tried, and a group with no winner fails setup with a named message.
- Register a live instance
The instance belongs to the run. Registering it here is what makes context.Client resolve instead of throwing.
samples/Northstar.ProtoTest/NorthstarScenario.cs.Two registrations wire it up, one for the initializer and one for the hook:
public static IProtoHostBuilder AddNorthstarTestSupport(this IProtoHostBuilder builder)
{
builder.ConfigureServices(services => services.AddSingleton<IProtoClientInitializer, ScenarioProbeInitializer>());
return builder.AddTestHook<NorthstarScenarioHook>();
}
The extension method is how the sample's Setup.cs stays readable: the integration owns its registrations, and the composition mentions one line.
The trace entry
The hook writes the correlation and the milestone trail. This is the event that opens a scenario:
1context.Trace.WriteEvent(2"northstar.scenario.begin",3"Begin correlated Northstar scenario",4"Northstar.ProtoTest",5outcome: ProtoTraceOutcome.Succeeded,6attributes: new Dictionary<string, string?>7{8["northstar.correlation_id"] = scenario.CorrelationId9});
- Kind: dotted, lowercase
The viewer groups by the prefix before the first dot, so northstar events get their own category without a viewer release.
- Name: for humans
The built-ins use AREA, verb, subject. Keep the same shape so a reader can scan the execution layer.
- Source: your package
The source names what wrote the entry, which is how a reader tells framework entries from yours.
northstar.scenario.begin and northstar.scenario.end, with a duration attribute on the closing one.The conventions are short and worth following:
- Attributes are strings. Keep them small, and never put a secret in one.
- Nesting is automatic. An operation started inside another becomes its child.
- Complete an operation once. A second completion has no effect. Disposing it without completion records
Unknown.
Your turn: one milestone
The probe is public, so a test can mark its own step. Add this to the first journey from lesson 1, under the [RunNote] you already added; if you removed that file, Write your first test recreates it, and Write your own attribute adds the note.
Proto.Context.Client<ScenarioProbe>("ScenarioProbe").Mark("first-milestone");
Run the filter and open the trace. The teardown publishes the scenario summary as an attachment. Open it and read the milestones:
{"CorrelationId":"scenario-416387000001-6406e159a16243e0beb564c28105893f","TestName":"Northstar.ProtoTest.ProjectsJourney.CreatingAProjectReturnsIt","DurationMs":349.127,"Milestones":["scenario-started","scenario-completed"]}
The committed archive for the first journey, l1-first-journey.prototrace, has the same shape, with the sample's own two milestones. Beside it, the trace holds:
| Entry | Reading |
|---|---|
Initialize · ScenarioProbe (ScenarioProbe), 0.1 ms | the initializer registered the custom client during setup |
Before · NorthstarScenarioHook, 5.9 ms | the hook ran before the test and wrote the opening event |
Publish · <test id>-scenario-summary.json, 0.5 ms | the hook attached the milestone trail at teardown |
Your milestone lands in the same attachment, because the client, the hook and the attachment are one integration. That is the whole point: a feature you write behaves like a feature that shipped.
Checkpoint
The first journey runs with the scenario hook registered. Which entry in its trace proves the custom client was registered, and where does your own milestone land?
Initialize · ScenarioProbe (ScenarioProbe): the initializer registered the custom client, which is what makes context.Client resolve instead of throwing. Your milestone lands in the same place the sample's two milestones do: the scenario summary attachment the hook publishes at teardown.What you learned
- There is one extension point per thing you want to add: attribute, hook, client, provisioner, sink or trace entry.
- A custom client is an initializer the host resolves and a context call that returns it.
- Kinds are dotted and lowercase, names are for humans, and the source is your package.