Precept 0.10.0

Dependency injection

Registrations go in one place — the IServiceCollection above — and reach both plain C# tests and Reqnroll step definitions.

Each test runs in its own DI scope, so the lifetimes mean what you would expect:

Lifetime Scope
AddSingleton One for the whole run. Shared by every scenario, including parallel ones.
AddScoped One per test, and per retry attempt. Disposed with the test.
AddTransient One per resolution.

Step definitions take their dependencies through the constructor. Precept builds [Binding] classes out of its own container, so a step class can mix both sides — a page object from ConfigureServices, a ScenarioContext from Reqnroll:

[Binding]
public class WebSteps(SignInPage signIn, ScenarioContext scenario)
{
    [When("I sign in as {string}")]
    public Task WhenISignIn(string user) => signIn.SignInAsync(user, "secret");
}

One instance of a binding class serves the whole scenario, as it does under Reqnroll's own container, so field state survives from step to step. A binding class that is IDisposable is disposed with the test. Registering a type with Reqnroll directly — from your own plugin or a [BeforeScenario] hook — still wins for that type.

Page objects#

A page object takes an IPageContext — registered scoped by AddPreceptWeb(), as is the PageContext class behind it, both resolving to one instance per test — and reads Page from it where it uses it. Locators are then plain properties, and no method on a page object has to be async just to reach the browser:

public sealed class SignInPage(IPageContext context)
{
    public ILocator Heading => context.Page.Locator("#heading");
    public ILocator Result  => context.Page.GetByTestId("result");

    public Task OpenAsync() => context.Page.GotoAsync("/");

    public async Task SignInAsync(string username, string password)
    {
        await context.Page.FillAsync("#username", username);
        await context.Page.ClickAsync("#submit");
    }
}
[Then("the page should say {string}")]
public Task ThenThePageShouldSay(string expected) =>
    Assert.That(signIn.Result, "the page").ToHaveTextAsync(expected);

The indirection is what makes moving between tabs work. Assign Page and every page object holding that context follows, because each one re-reads the property — including one that took the class where another took the interface:

var popup = await context.Page.RunAndWaitForPopupAsync(() => context.Page.ClickAsync("#invoice"));
context.Page = popup;                      // or: await context.NewPageAsync();

Read context.Page per use rather than copying it into a field in the constructor — a stored page keeps pointing at the tab it was, which is the staleness this type exists to avoid.

The same assignment is how a scenario with two signed-in users works. Each user is a session of its own, and pointing the context at one of them points every page object at that user:

context.Page = (await Browser.StartAsync("bob")).Page;
await signIn.SignInAsync("bob", password);

One context per test means the page objects move together, and that they point at one user at a time — so two users acting at the same moment, rather than in turn, is a case for their IPages directly.

Precept never opens a browser on its own. A session belongs to the test that started it, so the suite decides which tests pay for one — normally a tagged hook:

[Binding]
public class BrowserHooks
{
    [BeforeScenario("@web")]
    public Task StartBrowser() => Browser.StartAsync();
}

[BeforeTest] does the same for a plain C# suite, and a step that calls Browser.PageAsync() or Browser.GoToAsync(...) starts one implicitly. A test that reaches context.Page having started nothing fails saying so. Browser.Current answers whether this test has a session without starting one.

Precept 0.10.0 · MIT · © 2026

Esc