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.