Introduction
Lately I've been working on end-to-end tests and really liked the readability of Maestro tests. Maestro is a testing framework for mobile applications and uses YAML format. Here is an example:
Maestro Example
appId: org.wikipedia.wikipedia
---
- launchApp
- assertVisible: "Welcome to Wikipedia"
- runFlow: subflows/onboarding.yaml
- tapOn: "Year In Review"
- assertNotVisible: "Sign in to edit"
- inputText: "qwerty"
- assertVisible: "qwerty"
- eraseText
- inputText: "London"YAML is quite readable and works well for simple data structures. For more complex things maybe less so.
As I'm also working lately with Playwright in .NET, I wondered whether it is possible to make these tests as readable as Maestro tests, while having a proper programming language?
TLDR
Raw Playwright in F#
Here is a typical Playwright test in F#:
do! page.GotoAsync("https://localhost:5001")
do! page.GetByRole(AriaRole.Link, PageGetByRoleOptions(Name="Sign in"))
.ClickAsync()
do! page.GetByLabel("Username").FillAsync("jannik")
do! page.GetByLabel("Password").FillAsync("secret")
do! page.GetByRole(AriaRole.Button, PageGetByRoleOptions(Name = "Sign in"))
.ClickAsync()
do! Expect(page.GetByRole(AriaRole.Button, PageGetByRoleOptions(Name = "Create game")))
.ToBeVisibleAsync()Not bad per se, but quite verbose. Every line needs page. and do! (or let!), many functions will have an async suffix and locator/interaction invocations with arguments are a bit verbose.
Page Locator Record (PLR)
To avoid spelling out locators inline in every test, we can make them reusable. Let's collect them in a record — and call it a Page Locator Record (PLR):
type HomePage = {
SignInLink: ILocator
}
type LoginPage = {
UsernameField: ILocator
PasswordField: ILocator
SigninButton: ILocator
}
let homePage (page: IPage) = {
SignInLink = page.GetByRole(AriaRole.Link, PageGetByRoleOptions(Name = "Sign in"))
}
let loginPage (page: IPage) = {
UsernameField = page.GetByLabel("Username")
PasswordField = page.GetByLabel("Password")
SigninButton = page.GetByRole(AriaRole.Button, PageGetByRoleOptions(Name = "Sign in"))
}The test now reads:
let home = homePage page
let login = loginPage page
do! page.GotoAsync("https://localhost:5001")
do! home.SignInLink.ClickAsync()
do! login.UsernameField.FillAsync("jannik")
do! login.PasswordField.FillAsync("secret")
do! login.SigninButton.ClickAsync()Better — locators are named and reusable.
Looking at the PLR, every line uses the page value; can this be simplified? If every property is a function IPage -> ILocator, then we could defer getting to the actual ILocator. Let's create some helper functions:
[<AbstractClass; Sealed>]
type Locators() =
static member GetByLabel (text: string) (page: IPage) =
page.GetByLabel(text)
static member GetByRole role options (page: IPage) =
page.GetByRole(role, options)
static member Button text =
Pw.GetByRole AriaRole.Button (PageGetByRoleOptions(Name = text))
static member Heading text =
Pw.GetByRole AriaRole.Heading (PageGetByRoleOptions(Name = text))
static member Link text =
Pw.GetByRole AriaRole.Link (PageGetByRoleOptions(Name = text))
static member Label text =
Pw.GetByLabel text
// ...
The PLR no longer mentions page at all:
type Locator = IPage -> ILocator
type HomePage = {
SignInLink: Locator
}
type LoginPage = {
UsernameField: Locator
PasswordField: Locator
SigninButton: Locator
}
open type Locators
let homePage = {
SignInLink = Link "Sign in"
}
let loginPage = {
UsernameField = Label "Username"
PasswordField = Label "Password"
SigninButton = Button "Sign in"
}Each field is a function waiting for an IPage. The page will be supplied later.
Now we improved readability in the PLR, but can't easily use it in the test. We would need to pass page on every interaction and assertion to get ILocator.
Computation Expression
Each test line still has some noice:
do! home.SignInLink.ClickAsync()do! home and Async() are just noise. A custom Computation Expression can hide them.
Every test step has the same shape:
Using the current
PLRsselect a
Locatorin the testresolve the
ILocatorusingpagerun a Playwright method
We capture this in one helper:
type State<'plr> = Task<'plr>
let act (state:State<'plr>) (getLocator:'plr->Locator) (page:IPage) (f:ILocator->Task) =
task {
let! plr = state
let locator = getLocator plr page
do! f locator
return plr
}
The Custom Computation expression constructor is initialized with IPage and the initial PLR, then it can manage both values for us.
type UiTestBuilder<'initialPage>(page: IPage, plr: 'initialPlr) =A custom operation removes the "Async" suffix and just uses the previously seen helper.
[<CustomOperation("fill", MaintainsVariableSpaceUsingBind = true)>]
member _.Fill(state: State<'page>, locator, text: string) =
act state locator _.FillAsync(text)Then we create custom operations for all playwright functions we want simplified...
type UiTestBuilder<'initialPage>(page: IPage, plr: 'initialPlr) =
let act (state:State<'plr>) (getLocator:'plr->Locator) (f:ILocator->Task) =
task {
let! plr = state
let locator = getLocator plr page
do! f locator
return plr
}
let expect f (locator: ILocator) = f (Assertions.Expect(locator))
[<CustomOperation("fill", MaintainsVariableSpaceUsingBind = true)>]
member _.Fill(state: State<'page>, locator, text: string) =
act state locator _.FillAsync(text)
[<CustomOperation("click")>]
member _.Click(state, locator) =
act state locator _.ClickAsync()
[<CustomOperation("expectVisible", MaintainsVariableSpaceUsingBind = true)>]
member _.ExpectVisible(state, getLocator) =
act state getLocator (expect _.ToBeVisibleAsync())
We also need some CE plumbing in the form of Yield and Run:
member _.Yield(()) : State<'initialPlr> =
Task.FromResult plr
member _.Run(state: State<'page>) =
stateYield seeds the state with the initial PLR.Run runs the workflow and returns the PLR.
A test now looks like this:
do! pageTest page loginPage {
fill _.UsernameField "jannik"
fill _.PasswordField "secret"
click _.SigninButton
}No do!, no page., no Async suffix.
Context-Aware Tests: Changing the PLR on Actions
One problem remains: after clicking SigninButton we are on a different page, but the PLR is still loginPage. To fix this, navigating Locators will be enhanced with a transition — a pointer to the next PLR:
type Transition<'t> = Locator * (unit -> 't)A Transition<HomePage> is a tuple of the locator and a thunk returning the next PLR, in this case HomePage.
type LoginPage = {
UsernameField: Locator
PasswordField: Locator
SigninButton: Transition<HomePage>
// ^^^^^^^^^^^^^^^^^^^^
// clicking this takes us to HomePage
}
and HomePage = {
SigninLink: Transition<LoginPage>
CreateGameButton: Transition<CreateGameModal>
}
let rec login () = {
UsernameField = Label "Username"
PasswordField = Label "Password"
SigninButton = Button "Sign in", home
// ^^^^^^^^^^^^^^^^ ^^^^
// Locator Next Plr
}
and home () = {
SigninLink = Link "Sign in", login
CreateGameButton = Button "Create game", createGameModal
}Two more helpers alongside act handle the transition cases. One that actually executes the transition:
// execute the action and advance to the next PLR
let applyTransition (state:State<'plr>) (getTransition:'plr->Transition<_>) (page:IPage) (f:ILocator->Task) =
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// this helper works only on Transition elements
task {
let! plr = state
let locator, next = getTransition plr
do! f (locator page)
return next ()
// ^^^^^
// returning the next plr changes the state/context of the CE
}
// read the locator from a Transition but stay on the current PLR (for assertions)
let applyWithoutTransition (state:State<'plr>) (getTransition:'plr ->Transition<_>) (page:IPage) (f:ILocator->Task) =
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// this helper works only on Transition elements
task {
let! plr = state
let locator, _ = getTransition plr
do! f (locator page)
return plr
// ^^^
// Even though we the helper acts on a Transition,
// this helper does not execute it, it just returns the original PLR/state
}The CE gets a second overload for each operation — one for Locator, one for Transition:
// plain locator — PLR unchanged
[<CustomOperation("click")>]
member _.Click(state, locator) =
act state locator _.ClickAsync()
// transition locator — PLR advances
[<CustomOperation("click")>]
member _.Click(state, getTransition) =
applyTransition state getTransition page _.ClickAsync()
// plain locator - PLR unchanged
[<CustomOperation("expectVisible")>]
member _.ExpectVisible(state, getLocator) =
act state getLocator (expect _.ToBeVisibleAsync())
// assertions never navigate, so the Transition overload keeps the current PLR
[<CustomOperation("expectVisible")>]
member _.ExpectVisible(state, getLocator) =
applyWithoutTransition state getLocator page (expect _.ToBeVisibleAsync())F# resolves the overload by the type of the locator argument — 'plr -> Locator vs 'plr -> Transition<_>. In other words, when calling click on a Locator property, the PLR does not change, when calling click on a Transition<plr> property, the PLR automatically changes in the CE. From the test syntax perspective this is transparent. However as the context changes, on the next call to any custom operation, typing _. will autocomplete for properties on the new PLR.
Final version
The final test is context-aware. The PLR changes automatically as you navigate:
do! page.GotoAsync("https://localhost:5001")
do! pageTest page (plr.Home()) {
// PLR is initially Home
click _.SigninLink // Transition → PLR will become LoginPage
fill _.UsernameField "jannik"
fill _.PasswordField "secret"
click _.SigninButton // Transition → PLR will become HomePage
expectVisible _.CreateGameButton
click _.CreateGameButton // → PLR will become CreateGameModal
fill _.GameNameField "my-game"
fill _.MaxPlayersField "2"
fill _.RoundsField "2"
click _.CreateButton // → PLR will become GameDetailsPage
expectVisible _.LobbyHeading
}Each click on a Transition changes the PLR. The compiler enforces that you can only access fields that exist on the current page — you cannot accidentally reference a field from the wrong page state.
Closing
If you want to experiment with this approach, there is a more complete DSL here and here is a bigger example how it is used. You can also feed https://jannikbuschke.de/examples/playwright-dsl/agent-context.md into an agent and ask it to create tests for a given uri.
If you have any thoughts on this feel free to reach out or leave a comment. Thanks for reading!