Skip to main content

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.

NorthstarAttributes.cs4 notes
1[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
2public sealed class NorthstarTenantAttribute : ProtoAttribute
3{
4public NorthstarTenantAttribute(string planId = PlanIds.Free)
5{
6ArgumentException.ThrowIfNullOrWhiteSpace(planId);
7PlanId = planId;
8Order = -200;
9}
10
11public string PlanId { get; }
12
13public 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}
  1. Derive, do not configure

    Nothing registers the class. Putting it on a test is the whole wiring.

  2. 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.

  3. 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).

  4. Publish the result

    SetContext makes the tenant available to tests and to other attributes, and the trace records it.

From 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:

EntryReading
Before · NorthstarTenantAttribute, 139.9 msRuns first, at order -200.
Create · ProvisionTenantRequest, 137.0 msThe tenant attribute's call to the data surface.
Before · NorthstarMemberAttributeThe composite's own entry, carrying the attributes it composed.
After · NorthstarMemberAttribute, then After · NorthstarTenantAttributeTeardown reverses the order.
Release · data:TenantResponse:1, then Cleanup · TenantResponseThe 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?

Verify
Run the filtered test and open its trace. Compare the setup phase with the teardown phase.

Remember​

  • An attribute derives from ProtoAttribute and overrides BeforeTestAsync, and AfterTestAsync when it has something to undo.
  • Order sequences 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.