Skip to main content

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.

Level 6, lesson 2About 9 minutes
By the end
  • 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.
Before you start
  • 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:

NorthstarApiProvisioner.cs4 notes
1public abstract class NorthstarApiProvisioner<TRequest, TResponse> : IProtoDataProvisioner<TRequest, TResponse>
2{
3protected abstract string Url { get; }
4
5protected abstract object Body(TRequest value);
6
7protected virtual object? RouteValues(TRequest value) => null;
8
9protected abstract string IdOf(TResponse response);
10
11public 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}
  1. One seam, many fixtures

    The interface takes an input and a result type, so a request can differ from what the system returns.

  2. Name the identity

    The id the provisioner reports is how Ref<T> finds the value later, and what the release row names.

  3. Use the running test context

    context.Execution carries the clients, the configuration and the trace of the test that asked for the fixture.

  4. Require the contract

    A creation that did not answer 201 is a failed fixture, not a warning. The provisioner fails the test here.

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

EntryReading
Create · ProvisionTenantRequest, 137.0 msthe data surface received the request and looked up the registered provisioner
Build · ProvisionTenantRequest, 3.5 msthe defaults and the With calls produced the value that was sent
Provision · ProvisionTenantRequest → TenantResponse, 132.0 msthe provisioner made the call and returned the created value
Release · data:TenantResponse:1, 10.3 ms, then Cleanup · TenantResponseteardown 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:

Pages.cs3 notes
1public sealed class SignInPage : WebPage
2{
3public WebElement Token => Element(By.TestId("token"));
4
5public WebElement Submit => Element(By.TestId("login"));
6
7public WebElement Error => Element(By.TestId("error"));
8}
9
10public sealed class ProjectsPage : WebPage
11{
12public WebElement Table => Element(By.TestId("projects"));
13
14public WebElement Search => Element(By.TestId("search"));
15
16public WebComponentCollection<ProjectRow> Rows => Components<ProjectRow>(By.TestId("project"));
17
18public ProjectRow Project(string name) => Rows.Matching(By.HasText(name), $"Project[{name}]");
19}
  1. 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.

  2. A collection of components

    Rows are their own component class, so a row can hold the name, the status and the environment count.

  3. Matching is strict

    No match and more than one match are both errors. A filtered list either finds the one row or fails loudly.

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

Verify
Run the WebJourney filter above and open the execution layer, or read the web.* entries listed in this section.

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?

Verify
Read the provisioner reference, then the release rows of the first journey's trace.

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.

Keep exploring​