Write your own attribute
Level 6 returns to the sample suite. The first journey already carries [NorthstarMember], and that one line provisions a tenant and signs a member in. This lesson writes the next attribute yourself.
- Derive an attribute from ProtoAttribute and override its setup.
- Group attributes that always travel together into a composite.
- Find every attribute in the trace, before and after.
- Inject faults on purpose (Level 5, lesson 4).
- The sample cloned and open in an editor.
The scenario
A test declares what it needs. An attribute provides it. That split is why the sample's journeys carry one declaration each instead of a base fixture class full of setup.
The attributes the sample ships are ordinary public code. This lesson reads one and then writes one.
The sample's attribute
NorthstarTenantAttribute provisions an isolated organization for the test and removes it afterwards:
1[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]2public sealed class NorthstarTenantAttribute : ProtoAttribute3{4public NorthstarTenantAttribute(string planId = PlanIds.Free)5{6ArgumentException.ThrowIfNullOrWhiteSpace(planId);7PlanId = planId;8Order = -200;9}1011public string PlanId { get; }1213public override async Task BeforeTestAsync(ProtoExecutionContext context)14{15var tenant = await context.Data()16.For<ProvisionTenantRequest>()17.With(request => request.Name, context.UniqueName("northstar"))18.With(request => request.PlanId, PlanId)19.CreateAsync<TenantResponse>();20context.SetContext(new NorthstarOrganizationContext(21tenant.Tenant,22tenant.OrganizationId,23tenant.OwnerEmail,24tenant.OwnerToken,25tenant.ApiBaseUrl));26}27}
- Derive, do not configure
The class is the capability. Nothing registers it; declaring it on a test is the composition.
- Order says what it must precede
A lower order runs earlier. Provisioning a tenant must precede the signed-in identity, so this is negative and the member identity stays at 0.
- Provision through the data surface
The attribute does not open a connection or build a URL. It asks the data surface for a tenant, and the registered provisioner decides how that is done.
- Publish typed state
SetContext makes the tenant available to tests, authenticators and other attributes, and the value is recorded as traced state.
samples/Northstar.ProtoTest/NorthstarAttributes.cs. The companion NorthstarMemberAttribute is a composite: it declares a tenant attribute and the signed-in identity together.The composite is the part that keeps a test declaration short:
public sealed class NorthstarMemberAttribute(string planId = PlanIds.Free) : ProtoCompositeAttribute
{
public string PlanId { get; } = planId;
protected override IReadOnlyList<Attribute> Compose() =>
[
new NorthstarTenantAttribute(PlanId),
new AuthAttribute<NorthstarAuthenticator>(),
];
}
A composite expands into the attributes it names, in the same ordering rules. The trace shows both: each composed attribute has its own entries, and the composite's entries carry the types it expanded to.
Write one
Add a file to the sample project, RunNoteAttribute.cs:
namespace Northstar.ProtoTest;
using global::ProtoTest.Core;
/// <summary>Records why this test exists, so a run carries its reason next to its evidence.</summary>
public sealed class RunNoteAttribute(string note) : ProtoAttribute
{
public override Task BeforeTestAsync(ProtoExecutionContext context)
{
context.Trace.WriteEvent("run.note", note, "Northstar.ProtoTest");
return Task.CompletedTask;
}
}
Apply it to a test. The file from Level 1 works; if you removed it there, Write your first test has the snippet to recreate it, or apply the attribute to any journey in the sample:
[Application(NorthstarTargets.Api)]
[NorthstarMember]
[RunNote("first attribute")]
public sealed class MyFirstJourney
Run it alone:
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~MyFirstJourney"
Open bin/Debug/net8.0/TestResults/prototest-{runId}.prototrace under the sample project in the viewer. Each run writes its own file, named after the run id. The setup phase holds a Before · RunNoteAttribute entry and the event you wrote, and the teardown phase holds the matching After entry.
The evidence the sample leaves
The committed archive for the first journey, l1-first-journey.prototrace, shows the same mechanism, with a real provisioning chain inside the attribute:
| Entry | Reading |
|---|---|
Before · NorthstarTenantAttribute, 139.9 ms | the attribute runs first, at Order -200 |
Create · ProvisionTenantRequest, 137.0 ms, with Build and Provision below it | the data surface builds the request, creates it and returns the response |
Before · NorthstarMemberAttribute | the composite's own entry, carrying the attributes it composed |
Apply · NorthstarAuthenticator, 1 ms | the HTTP auth hook applies the member's token on the first call |
After · NorthstarMemberAttribute, then After · NorthstarTenantAttribute, with data.cleanup Cleanup · TenantResponse | teardown reverses the order and the provisioned tenant is removed |
That is the whole attribute contract in one page: a declaration on a test, a before step, typed state, an after step, and a trace that shows each one.
Checkpoint
The tenant attribute declares Order -200 and your new attribute keeps the default 0. Of those two, which before entry comes first, and in what order do their after entries appear in teardown?
What you learned
- An attribute derives from ProtoAttribute and overrides BeforeTestAsync and AfterTestAsync.
- Order sequences attributes that depend on each other, and teardown runs in reverse.
- Every attribute gets its own before and after entry, so the trace shows what actually executed.