Write your own attribute
The problem
Every test in your project starts with the same setup: create an organization, sign a user in, note why the test exists. You could copy that code into each test or hide it in a base class. Both get heavy as the suite grows.
An attribute is a C# attribute that prepares something for a test and cleans it up afterwards. The test declares what it needs, and the attribute provides it. The sample's journeys carry one line, [NorthstarMember], and that line creates a tenant and signs a member in.
Do it
1. Read the sample's attribute
NorthstarTenantAttribute creates an isolated organization for the test. The data surface it calls, context.Data(), creates test data and registers its cleanup, so the attribute does not remove the tenant itself.
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
Nothing registers the class. Putting it on a test is the whole wiring.
- Order says what runs first
A lower order runs earlier. The tenant must exist before the signed-in member, so this is negative and the member stays at 0.
- Ask for data, not for a connection
The attribute asks the data surface for a tenant. A registered provisioner decides how it is created (the next lesson).
- Publish the result
SetContext makes the tenant available to tests and to other attributes, and the trace records it.
samples/Northstar.ProtoTest/NorthstarAttributes.cs.2. See how attributes group
Most tests need a tenant and a signed-in user together. NorthstarMemberAttribute is a composite attribute that names both, so a test writes one line:
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>(),
];
}
3. Write your own
Add RunNoteAttribute.cs to the sample project. It writes a note into the trace, so a run carries its reason next to its evidence:
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;
}
}
4. Apply it and run it
Use the first test from Write your first test, or add the attribute to any journey in the sample:
[Application(NorthstarTargets.Api)]
[NorthstarMember]
[RunNote("first attribute")]
public sealed class MyFirstJourney
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~MyFirstJourney"
Open bin/Debug/net8.0/TestResults/prototest-{runId}.prototrace under the sample project in the viewer. The setup phase holds a Before · RunNoteAttribute entry and the event you wrote. The teardown phase holds the matching After entry.
What happened
The host found your attribute on the test and ran its BeforeTestAsync before the test body. Attributes run in ascending Order before the test, and in reverse afterwards. The last thing set up is the first thing cleaned up.
The committed trace of the first journey, l1-first-journey.prototrace, shows the sample's attributes the same way:
| Entry | Reading |
|---|---|
Before · NorthstarTenantAttribute, 139.9 ms | Runs first, at order -200. |
Create · ProvisionTenantRequest, 137.0 ms | The tenant attribute's call to the data surface. |
Before · NorthstarMemberAttribute | The composite's own entry, carrying the attributes it composed. |
After · NorthstarMemberAttribute, then After · NorthstarTenantAttribute | Teardown reverses the order. |
Release · data:TenantResponse:1, then Cleanup · TenantResponse | The data surface removes the tenant it created. |
Check yourself
The tenant attribute declares Order -200 and your new attribute keeps the default 0. Which before entry comes first, and in what order do their after entries appear in teardown?
The tenant attribute's before entry comes first, so it appears before yours in the setup phase. Teardown runs in reverse, so its after entry comes after yours.
Remember
- An attribute derives from
ProtoAttributeand overridesBeforeTestAsync, andAfterTestAsyncwhen it has something to undo. Ordersequences attributes that depend on each other, and teardown runs in reverse.- A composite attribute groups attributes that always travel together.
Next: create test data with a provisioner, where the data an attribute creates comes from.
Go deeper
- Attributes: ordering, composites, skip conditions and the trace view.