Control flow
Serenity/JS introduces idiomatic control flow constructs to support implementing conditional and repeated activities in your Screenplay Pattern scenarios.
Consistently with the design of Serenity/JS assertions and synchronisation statements, all the Serenity/JS control flow statements rely on reusable expectations.
Conditional statements​
Check.whether
is the Serenity/JS equivalent of an if...else
statement.
However, while if...else
is a construct limited to operating on static values, Check.whether
evaluates
the provided value dynamically in the context of the given actor:
import { actorCalled, Check } from '@serenity-js/core'
import { PageElement, By, isVisible, Click } from '@serenity-js/web'
class CookieConsent {
static banner = () =>
PageElement.located(By.id('cookie-consent'))
.describedAs('cookie consent banner')
static closeButton = () =>
PageElement.located(By.css('.close'))
.describedAs('close button')
.of(this.banner())
}
await actorCalled('Chuck').attemptsTo(
Check.whether(CookieConsent.banner(), isVisible())
.andIfSo(
Click.on(CookieConsent.closeButton()),
)
)
Optionally, the interaction to Check
can also define the alternative activities
to perform when the condition is not met:
import { actorCalled, Check, Log } from '@serenity-js/core'
import { isVisible, Click } from '@serenity-js/web'
await actorCalled('Chuck').attemptsTo(
Check.whether(CookieConsent.banner(), isVisible())
.andIfSo(
Click.on(CookieConsent.closeButton()),
)
.otherwise(
Log.the('Cookies already accepted')
)
Loops​
Serenity/JS List
offers a forEach
method that
- enables actors to iterate over synchronous and asynchronous collections,
- provides access to the actor iterating over the list as well as the item accessed in the current iteration
- ensures correct synchronisation of actor's activities.
Since PageElements
class derives from List
, it also offers this functionality.
Iterating over a static Array​
To instruct an actor to iterate over a static Array
,
wrap it using List.of
:
import { actorCalled, List, Log } from '@serenity-js/core'
const items = List.of([ 'first', 'second', 'third' ]);
await actorCalled('Joe').attemptsTo(
items.forEach(({ actor, item }) => actor.attemptsTo(
Log.the('current item', item)
)),
)
Iterating over REST API responses​
List.of
pattern also applies to
wrapping iterables returned from a Question
or QuestionAdapter
.
This is particularly useful when iterating over responses from REST APIs:
{
"products": [
{ "name": "apples" },
{ "name": "bananas" },
{ "name": "cinnamon rolls" }
]
}
import { actorCalled, List, Log } from '@serenity-js/core'
import { GetRequest, LastResponse, Send } from '@serenity-js/rest'
interface ProductsResponse {
products: Array<{ name: string }>
}
await actorCalled('Apisit')
.attemptsTo(
Send.a(GetRequest.to('/products')),
List.of(LastResponse.body<ProductsResponse>().products)
.forEach(({ actor, item }) => actor.attemptsTo(
Log.the(item),
))
)
Iterating over web UI elements​
PageElements
is derived from List
, so it also offers a forEach
method:
<label for="tnc-consent">
<input type="checkbox" name="tnc-consent">
Yes, I agree with terms and conditions
</label>
<label for="newsletter-consent">
<input type="checkbox" name="newsletter-consent">
Yes, I'd like to receive the newsletter
</label>
import { actorCalled } from '@serenity-js/core'
import { PageElements, By, Click } from '@serenity-js/web'
const checkboxes = () =>
PageElements.located(By.css('[type="checkbox"]'))
.describeAs('checkboxes')
await actorCalled('Francis')
.attemptsTo(
checkboxes().forEach(({ actor, item }) => actor.attemptsTo(
Click.on(item),
))
)
Synchronisation caveats​
All the activities performed by Serenity/JS actors are asynchronous.
While the framework takes care of sequencing them correctly when they're executed
via Actor.attemptsTo
,
the Promise
returned by this method itself must be synchronised with the test runner.
This is very easy to do using async/await
,
like in this example:
import { describe, it } from 'mocha'
import { actorCalled, Log } from '@serenity-js/core'
describe('Serenity/JS actor.attemptsTo', () => {
it('returns a Promise', async () => { // note "async"
await actorCalled('Joe').attemptsTo( // note "await"
Log.the('current item')
)
})
})
However, most low-level JavaScript control flow constructs DO NOT SUPPORT async/await
.
For example, this listing DOESN'T WORK as
Array.forEach
expects a synchronous function and does not wait for promises.
This means that the promise return by Actor.attemptsTo
is ignored and the behaviour of the test scenario becomes unpredictable:
import { describe, it } from 'mocha'
import { actorCalled, Log } from '@serenity-js/core'
describe('Serenity/JS actor.attemptsTo', () => {
it('returns a Promise', async () => {
// BROKEN, DON'T USE! JavaScript forEach doesn't support Promises
await [ 'first', 'second', 'third' ]
.forEach(async item => { // async/await ignored!
await actorCalled('Joe').attemptsTo(
Log.the('current item', item)
)
})
})
})
If you want to iterate over a native JavaScript Array,
make sure to do so using a construct that supports async iterables,
such as for...of
:
import { describe, it } from 'mocha'
import { actorCalled, Log } from '@serenity-js/core'
describe('Serenity/JS actor.attemptsTo', () => {
it('returns a Promise', async () => {
const items = [ 'first', 'second', 'third' ];
for (const item of items) { // for...of
await actorCalled('Joe').attemptsTo( // supports async/await
Log.the('current item', item)
)
}
})
})
Of course, the most flexible approach is to use the Serenity/JS-native List
data structure,
which provides a custom implementation of forEach
that returns a task and DOES SUPPORT
asynchronous operations:
import { describe, it } from 'mocha'
import { actorCalled, List, Log } from '@serenity-js/core'
describe('Serenity/JS actor.attemptsTo', () => {
it('returns a Promise', async () => {
const items = List.of([ 'first', 'second', 'third' ]);
await actorCalled('Joe').attemptsTo(
items.forEach(({ actor, item }) => actor.attemptsTo(
Log.the('current item', item)
)),
)
})
})
List.forEach
allows loops to be encapsulated in other tasks,
and is therefore the recommended approach:
import { describe, it } from 'mocha'
import { actorCalled, List, Log, Task } from '@serenity-js/core'
describe('Serenity/JS actor.attemptsTo', () => {
const logEachOf = <T>(items: List<T>) =>
Task.where(`#actor logs each item`,
items.forEach(({ actor, item }) => actor.attemptsTo(
Log.the('current item', item)
)),
)
it('returns a Promise', async () => {
const items = List.of([ 'first', 'second', 'third' ]);
await actorCalled('Joe').attemptsTo(
logEachOf(items),
)
})
})