Screenplay
A screenplay suite is written as people doing things rather than as pages being driven. An actor holds the abilities they were granted, performs interactions against targets, and is asked questions about what they can see:
using Precept.Screenplay;
using Precept.Screenplay.Web;
using Precept.Web;
var alice = await Actor.Named("alice").WhoCanBrowseTheWebAsync();
await alice.AttemptsToAsync(
Navigate.To("/login"),
Enter.The("ada@example.com").Into(SignInPage.Email),
Enter.TheSecret(password).Into(SignInPage.Password),
Click.On(SignInPage.Submit));
await Assert.That(alice.Sees(Dashboard.Greeting)).ToHaveTextAsync("Hello, Ada");
It is entirely optional and it replaces nothing — page objects and screenplay can share a project. What it is for is structure. Screenplay is the shape a suite takes when the SOLID principles are applied to test code, which is the reason to reach for it: you want behaviour composed out of small, single-purpose, substitutable pieces rather than accumulated on page objects that grow a method every time a scenario needs something.
| Single responsibility | An interaction does one thing and is named after it. A page object tends towards being the page's entire API, edited by everyone and understood by nobody. |
| Open/closed | New behaviour is a new interaction or a new task. Nothing that already works is opened to make room for it. |
| Liskov substitution | Everything an actor performs is an IInteraction, so a built-in step, a task of your own and a composite of both are interchangeable wherever one is accepted — which is what lets tasks be built out of tasks. |
| Interface segregation | IInteraction and IQuestion<T> have one member each, and abilities are granted one at a time, so nothing depends on more of an actor than it uses. |
| Dependency inversion | An interaction depends on an ability, not on Playwright. BrowseTheWeb is the only type here that knows a browser exists, so the same task can be performed through a different ability against a different interface. |
Two people in one scenario is a consequence of that — an ability belongs to an actor, so two actors are two of everything — rather than the reason for it. A suite with exactly one user gets the whole of the structure above and none of the multi-user machinery it never asks for.
It ships as a package of its own, Precept.Screenplay, which brings Precept.Web with it — a suite
that drives a browser through page objects installs Precept.Web alone and is offered none of this:
dotnet add package Precept.Screenplay
The actor types live in Precept.Screenplay and the browser ones in Precept.Screenplay.Web —
neither in the root Precept namespace the rest of the framework uses, so a suite that does not
write screenplay is never offered any of it by an editor's completion list.
An actor#
Actor.Named("alice") brings an actor on, and asking again in the same test gives the same one — so
a hook can equip them and a step can simply use them:
[BeforeScenario("@web")]
public Task CastTheScenario() => Actor.Named("alice").WhoCanBrowseTheWebAsync();
An actor belongs to the test that asked for them, and to the attempt: a retry runs in a new test context and therefore starts with an empty cast. That is deliberate. An actor carried into the second attempt would still be holding the page the first one failed on, which is the same staleness a page object avoids by reading its page where it uses it.
Outside a test there is nowhere to file an actor that does not outlive the run, so Actor.Named
says so rather than inventing one. A run-level hook that needs a browser opens a session directly
instead — see signing in once for the whole run.
Abilities#
An ability is the only place an interaction touches the outside world, which is what keeps the
interactions themselves free of any module: Click.On(…) is a sentence about intent, and
actor.Using<BrowseTheWeb>() is where it becomes Playwright.
BrowseTheWeb is backed by a browser session named after the actor, so alice and
bob are two isolated identities — their own cookies, their own storage — without the scenario
arranging anything. It also means an actor starts from whatever was last saved under their name, so a
suite that signs its users in once gets that for free.
Actor.Named("alice").WhoCanBrowseTheWeb(); // the session is opened when something needs it
await Actor.Named("bob").WhoCanBrowseTheWebAsync(); // opened now
Actor.Named("auditor").WhoCan(BrowseTheWeb.In("admin")); // an identity that is not their name
Asking an actor for an ability they were never granted names the actor, what they could not do, and what they can do — because the usual cause is a hook that equipped somebody else.
Targets#
A target is a named element, described once and resolved against whichever actor is looking:
public static class SignInPage
{
public static readonly Target Email = Target.The("the email box", page => page.GetByLabel("Email"));
public static readonly Target Submit = Target.The(
"the sign-in button",
page => page.GetByRole(AriaRole.Button, new() { Name = "Sign in" }));
}
It holds a recipe, not a locator, so one declared static readonly is safe to share across tests,
actors and retries. The name is what a log line and a failure message say — "clicks the sign-in
button" rather than a selector.
Interactions#
Each interaction is an immutable class with its factory on itself: Click.On(target) returns a
Click. Because an interaction carries no page of its own, a step a suite repeats can be built once
and kept:
private static readonly IInteraction OpenSignIn = Navigate.To("/login");
One that takes no parameters has nothing to tell two instances apart, so it is exposed as the single
one there is ever a reason to have — Reload.ThePage.
What the module ships#
| Going somewhere | Navigate.To(url), Navigate.Back, Navigate.Forward, Reload.ThePage |
| Pointing | Click.On(t), DoubleClick.On(t), Hover.Over(t), Drag.The(a).Onto(b), Scroll.To(t) |
| Typing | Enter.The(x).Into(t), Enter.TheSecret(x).Into(t), Enter.The(x).KeyByKeyInto(t), Clear.The(t), Press.The("Enter"), Press.The("Enter").On(t) |
| Forms | Select.TheOption(label).In(t), Select.TheValue(v).In(t), Select.TheOptions(…), Select.TheValues(…), Check.The(t), Uncheck.The(t), Upload.The(path).To(t), Upload.These(…), Upload.Nothing.To(t) |
| Waiting | Wait.For(t), Wait.For(t).ToBe(WaitForSelectorState.Hidden), Wait.ForTheUrl("**/orders/*"), Wait.ForTheLoadState(LoadState.NetworkIdle) |
| Recording | TakeScreenshot.OfThePage, TakeScreenshot.Of(t), either .Named("after sign-in") |
| Anything else | Do.OnThePage(description, page => …), Do.On(t, description, element => …) |
There is one interaction per Playwright method and no more. A right-click is a Click with a
button, not an interaction of its own; a page that has to be signed into is a task, not a
step. What decides is whether Playwright has a method for it.
Enter.TheSecret types the value unchanged and writes •••••• to the log. Worth reaching for by
default on a credential: a test's log is published as standard output and forwarded to every
reporter, so a password typed with Enter.The ends up in the TRX file and in
ReportPortal.
There is no wait for a fixed length of time, deliberately — a sleep long enough to be reliable is
longer than the suite can afford across a thousand steps. Wait itself is needed far less often
than it looks, because Playwright waits for an element before acting on it and a web
assertion waits for the state it asserts; what is left is waiting for
something nothing is about to act on, such as a spinner going away before a value is read.
A screenshot is attached to the running test exactly as
CaptureScreenshotAsync attaches one, so it reaches the artifacts and every reporter,
and an actor's carries their session's name — two actors in one scenario do not overwrite each
other's.
Playwright's own options#
Every interaction takes them through .With(…), which copies the options and hands back a new
interaction. Precept does not restate a single one of Playwright's knobs:
Click.On(Board.Card).With(new() { Button = MouseButton.Right });
Click.On(Board.Card).With(new() { Modifiers = [KeyboardModifier.Shift] });
Navigate.To("/report").With(new() { WaitUntil = WaitUntilState.NetworkIdle });
Enter.The(query).KeyByKeyInto(Search.Box).With(new() { Delay = 50 });
Upload.The("fixtures/passport.png").To(Profile.Avatar).With(new() { Timeout = 30_000 });
The options object is Playwright's own, and the target of the new() is whatever that method takes —
LocatorClickOptions for a click, PageGotoOptions for a navigation. An interaction stays immutable
through it, so one built once and shared is not changed by a test that adds a timeout to a copy.
Anything Playwright can do#
Do is the escape hatch, and the reason the table above does not have to be exhaustive. It takes the
log line first, because that line is the whole difference between a step and a lambda:
await alice.AttemptsToAsync(
Do.OnThePage("dismisses the cookie banner", page => page.EvaluateAsync("localStorage.setItem('cookies', 'ok')")),
Do.On(Player.Timeline, "scrubs to the middle", timeline =>
timeline.HoverAsync(new() { Position = new() { X = 120, Y = 4 } })));
Write the description in the third person and without the actor's name, the way every other
interaction does — the actor's name is put in front of it. A step used more than twice is worth
promoting into a task or an interaction of your own; Do is for the one-off.
What the log says#
Every step is written to the test's log before it runs, under the name of whoever performed it:
alice signs in as ada@example.com
alice opens /login
alice enters 'ada@example.com' into the email box
alice enters '••••••' into the password box
alice clicks the sign-in button
Before, not after, so a failed run's log ends on the step that failed rather than the one before it. The name in front of every line is what keeps two actors readable when they are acting at the same moment.
Tasks#
A task is an interaction made of interactions. There is no separate interface: implement
IInteraction and hand the steps back to the actor, which is also what indents them under it in the
log.
public sealed class SignIn(string email, string password) : IInteraction
{
public string Description => $"signs in as {email}";
public Task PerformAsAsync(Actor actor) => actor.AttemptsToAsync(
Navigate.To("/login"),
Enter.The(email).Into(SignInPage.Email),
Enter.TheSecret(password).Into(SignInPage.Password),
Click.On(SignInPage.Submit));
}
A step that throws stops the ones after it — a task is a sequence, not a best effort.
Questions and assertions#
actor.Sees(target) hands back the element that actor is looking at, so an assertion begins at
Assert.That exactly as it does everywhere else and keeps Playwright's own waiting:
await Assert.That(alice.Sees(Dashboard.Greeting)).ToHaveTextAsync("Hello, Ada");
await Assert.That(bob.Sees(Dashboard.AdminMenu)).Not.ToBeVisibleAsync();
Every web assertion therefore applies unchanged, and screenplay adds no assertion vocabulary of its own. Nothing here polls: a Precept poll wrapped around a web-first assertion would multiply the two timeouts together.
A question is for a value the test wants to carry somewhere else — compare against something it read earlier, hand to another actor, put in a message:
| Text | Text.Of(t) trimmed, Texts.Of(t) for every element a target matches |
| Inputs | Value.Of(t) |
| The markup | HtmlAttribute.Called("href").Of(t), null where there is no such attribute |
| The page | Url.OfThePage, Title.OfThePage |
| Counting and branching | Count.Of(t), Visibility.Of(t) — both read the page as it stands, without waiting |
| Anything else | Ask.OnThePage(description, page => …), Ask.About(t, description, element => …) |
Questions are asserted about through the assertion engine's own entry points:
await Assert.That(await alice.AsksForAsync(Text.Of(Basket.Total))).ToBeEqualAsync("£42.00");
await Assert.Eventually(() => alice.AsksForAsync(Text.Of(Basket.Total)), "the order total")
.ToBeEqualAsync("£42.00");
The second re-reads until it holds, which is what a value the application is still working towards needs. A question is called again on every pass, so it must read and never act.
Ask is the questions' escape hatch, and takes its description as a noun phrase — it is what an
Assert.Eventually failure says it was waiting for:
var colour = Ask.About(Basket.Total, "the total's colour",
total => total.EvaluateAsync<string>("el => getComputedStyle(el).color"));
await Assert.Eventually(() => alice.AsksForAsync(colour), "the total's colour")
.ToBeEqualAsync("rgb(0, 128, 0)");
Two users at once#
Because an ability is granted per actor, two actors are two independent sets of everything: their own session, their own cookies, their own page. They can act at the same moment rather than a scenario switching a single browser between them:
var alice = await Actor.Named("alice").WhoCanBrowseTheWebAsync();
var bob = await Actor.Named("bob").WhoCanBrowseTheWebAsync();
await alice.AttemptsToAsync(new ShareTheDocument(with: "bob"));
await Assert.That(bob.Sees(Notifications.Latest)).ToHaveTextAsync("Alice shared a document");
The sessions are the same ones named sessions already provide — screenplay reaches them by a different door rather than adding a second mechanism.
With Gherkin#
Step definitions hold no state of their own. A scenario normally names the person once and then stops
mentioning them, so the step that introduces them brings them on and every step after it asks for
Actor.Current:
Scenario: A driver books the earliest slot
Given the "ada" user is signed in
When the user opens the catalogue
And the user books the earliest slot
Then the user should see the booking confirmed
[Binding]
public class BookingSteps
{
[Given("the {string} user is signed in")]
public Task SignedIn(string who) =>
Actor.Named(who).WhoCanBrowseTheWeb().AttemptsToAsync(new SignIn(Users.For(who)));
[When("the user opens the catalogue")]
public Task OpensTheCatalogue() => Actor.Current.AttemptsToAsync(new OpenTheCatalogue());
[Then("the user should see the booking confirmed")]
public Task SeesTheConfirmation() =>
Assert.That(Actor.Current.Sees(Booking.Confirmation)).ToBeVisibleAsync();
}
Actor.Current is whoever was last brought on by Actor.Named — the same person the scenario's
reader has in mind. Actor.Named is what puts them there, so a [Given] that introduces somebody
needs to do nothing else, and a [BeforeScenario] hook that casts the scenario works just as well.
Their name is still on them, so a step can use it: Actor.Current.Name.
Asking before anyone has been brought on says so and says where to do it, rather than acting as somebody the scenario never introduced.
Where a scenario does name people — because it has more than one — take the name as a step argument and there is no current actor to think about. The same steps then serve however many users the scenario has:
Scenario: A shared document reaches the other user
Given alice has signed in
When alice shares the document with bob
Then bob sees a notification
[Given("{word} has signed in")]
public Task SignedIn(string who) =>
Actor.Named(who).WhoCanBrowseTheWeb().AttemptsToAsync(new SignIn(Users.For(who)));
[Then("{word} sees a notification")]
public Task Notified(string who) =>
Assert.That(Actor.Named(who).Sees(Notifications.Latest)).ToBeVisibleAsync();
Name them in the steps of a scenario where two actors act at the same moment, too: two
AttemptsToAsync calls inside one Task.WhenAll have no last-one-named between them.
Because the cast is per test — and so is the current actor — two scenarios running in parallel each
have their own alice, and a retry starts with
nobody on. See the execution model.