Provisioners and page objects
Two problems come back in every journey: creating a fixture through the real door, and reading a browser screen as something other than markup. The sample answers both in ordinary classes you can copy.
- Read a provisioner: the request in, the created value out, the identity and the cleanup.
- Register one and follow its chain through a trace.
- Model a screen as a page object and find its component paths in a trace.
- Write your own attribute (lesson 1).
- The sample cloned. The browser journey also needs Playwright's Chromium; it skips with a reason without it.
The scenario
Setup data has to come from somewhere. A test that inserts rows directly bypasses product rules. A fixture copied into each test drifts from the endpoint it matched.
A provisioner creates the object once, through the door you choose, and returns what the system gave back. A page object describes a screen so a test reads as a user instead of a selector.
The provisioner contract
Every provisioner implements one method:
public interface IProtoDataProvisioner<TInput, TResult>
{
ValueTask<ProtoDataProvisioningResult<TResult>> CreateAsync(
TInput value,
ProtoDataProvisioningContext context,
CancellationToken cancellationToken);
}
The sample gives its API provisioners one shared shape so they cannot drift:
1public abstract class NorthstarApiProvisioner<TRequest, TResponse> : IProtoDataProvisioner<TRequest, TResponse>2{3protected abstract string Url { get; }45protected abstract object Body(TRequest value);67protected virtual object? RouteValues(TRequest value) => null;89protected abstract string IdOf(TResponse response);1011public async ValueTask<ProtoDataProvisioningResult<TResponse>> CreateAsync(12TRequest value,13ProtoDataProvisioningContext context,14CancellationToken cancellationToken)15{16using var response = await context.Execution.Rest()17.Body(Body(value))18.PostAsync(Url, RouteValues(value), ct: cancellationToken);19response.Should.HaveHttpStatus(HttpStatusCode.Created);20var created = response.ReadAsJson<TResponse>()21?? throw new InvalidOperationException(22$"The sample app returned no provisioned {typeof(TResponse).Name}.");23return new ProtoDataProvisioningResult<TResponse>(created, IdOf(created));24}25}
- One seam, many fixtures
The interface takes an input and a result type, so a request can differ from what the system returns.
- Name the identity
The id the provisioner reports is how Ref<T> finds the value later, and what the release row names.
- Use the running test context
context.Execution carries the clients, the configuration and the trace of the test that asked for the fixture.
- Require the contract
A creation that did not answer 201 is a failed fixture, not a warning. The provisioner fails the test here.
samples/Northstar.ProtoTest/Provisioners/. A concrete provisioner names only the URL, the body and the id, like NorthstarMemberProvisioner.Registration names the pair and the implementation:
builder.AddDataProvisioner<InviteMemberRequest, MembershipResponse, NorthstarMemberProvisioner>();
The sample registers its set once, and a defaults module fills values every fixture shares:
public sealed class NorthstarDataDefaults : IProtoDataDefaultsModule
{
public void Configure(ProtoDataConfiguration data)
{
data.For<ProvisionTenantRequest>()
.Default(request => request.PlanId, PlanIds.Free);
data.For<InviteMemberRequest>()
.Default(
request => request.Email,
context => $"member-{context.TestId}-{context.ObjectSequence:D4}@example.test");
}
}
Use one, then read the chain
A test provisions a project with the extension the sample keeps for it:
var project = await Proto.Context.Data().CreateProjectAsync($"provision-{Proto.Context.TestId}");
One call, and the trace records a chain. From the first journey's archive, l1-first-journey.prototrace, where the tenant attribute made the same call:
| Entry | Reading |
|---|---|
Create · ProvisionTenantRequest, 137.0 ms | the data surface received the request and looked up the registered provisioner |
Build · ProvisionTenantRequest, 3.5 ms | the defaults and the With calls produced the value that was sent |
Provision · ProvisionTenantRequest → TenantResponse, 132.0 ms | the provisioner made the call and returned the created value |
Release · data:TenantResponse:1, 10.3 ms, then Cleanup · TenantResponse | teardown released the tracked value and ran the cleanup |
Nothing in the test knew a port or a route. The registration decided which implementation ran, and the trace names it.
Model the screen
The same idea on the browser side. Pages.cs describes the two screens the browser journey uses:
1public sealed class SignInPage : WebPage2{3public WebElement Token => Element(By.TestId("token"));45public WebElement Submit => Element(By.TestId("login"));67public WebElement Error => Element(By.TestId("error"));8}910public sealed class ProjectsPage : WebPage11{12public WebElement Table => Element(By.TestId("projects"));1314public WebElement Search => Element(By.TestId("search"));1516public WebComponentCollection<ProjectRow> Rows => Components<ProjectRow>(By.TestId("project"));1718public ProjectRow Project(string name) => Rows.Matching(By.HasText(name), $"Project[{name}]");19}
- One property, one element
A property resolves its element against the live page each time it is read, so a re-rendered screen does not leave a stale handle.
- A collection of components
Rows are their own component class, so a row can hold the name, the status and the environment count.
- Matching is strict
No match and more than one match are both errors. A filtered list either finds the one row or fails loudly.
samples/Northstar.ProtoTest/Pages.cs. The wait the journeys use lives beside it in NorthstarPages.Wait.The journey then reads like a user:
var signIn = Proto.Context.Web().Page<SignInPage>();
await signIn.OpenAsync("/login");
await signIn.Flow("Sign in with the tenant token")
.Fill(page => page.Token, organization.OwnerToken)
.Click(page => page.Submit)
.RunAsync();
var projects = Proto.Context.Web().Page<ProjectsPage>();
var row = projects.Project(name);
await row.Status.Should.HaveTextAsync(ProjectStatuses.Active, NorthstarPages.Wait);
Run it and read its trace:
dotnet test samples/Northstar.ProtoTest --filter "FullyQualifiedName~WebJourney"
The execution layer holds web.navigate for the open, web.flow for the sign-in flow and the filter flow, web.fill and web.click for the steps, and assert.web for the status check. Each entry carries the component path, from ProjectsPage down to the element the step touched, so a failure names the screen, the component and the element instead of a CSS selector.
If the journey skips, the reason names Playwright and the browser it could not find. The web integration page covers the install and the probe. The unit of work is the same either way: the page object is ordinary code, and the run records what it touched.
A step in the browser journey fails on the projects screen. What three things does the trace name, and what would a raw selector name instead?
web.* entries listed in this section.ProjectsPage down to the element the step touched. A raw selector would name only the CSS that found it, not the screen it belongs to.Checkpoint
A provisioner returns a result with an identity and a cleanup. What does the identity let another call do, and what happens to the cleanup when the test ends?
Ref<T> resolves a value by its type and identity. The cleanup, when the provisioner supplies one, is disposed at teardown in reverse creation order and recorded as a data.cleanup operation.What you learned
- A provisioner turns a built request into a created value and reports its identity.
- Registering it is one line on the host builder, and the same call works through the API or the domain.
- A page object names elements as properties, and the trace carries the component path of what was touched.