Serenity 3.0 - Type-safe Notepad
See original GitHub issueA follow up to https://github.com/serenity-js/serenity-js/issues/817, cc: @viper3400
Context
The current 3.0-rc implementation of the Notepad makes it easy for developers to create untyped notepads:
actorCalled('Nate')
.whoCan(TakeNotes.using(Notepad.empty()))
.attemptsTo(
Note.record('example_note', 'content'),
Ensure.that(Note.of('example_note').isPresent(), true),
Ensure.that(Note.of('example_note'), equals('content'),
)
However, an untyped Notepad also makes it easy to:
- make typing errors when specifying the name of the note to be retrieved
- forget to update the name of the note, especially in deeply-nested tasks, when the name changes
It seems like using an (optionally) strongly-typed Notepad that defines the types of notes that can be stored in it would address this problem.
Here again the current 3.0-rc implementation allows us to define a strongly-typed Notepad.
For example, given the following structure of MyNotes:
interface PersonalDetails {
name: string;
age: number;
}
interface MyNotes {
personal: PersonalDetails;
}
We can define the notepad as follows:
Notepad.empty<MyNotes>()
Notepad.with<MyNotes>({
personal: {
name: 'Alice',
age: 27,
}
})
We can also retrieve the strongly-typed notes as follows:
actorCalled('Alice')
.whoCan(TakeNotes.using(Notepad.empty<MyNotes>()))
.attemptsTo(
Note.record<MyNotes>('personal', { name: 'Alice', age: 27 }),
Ensure.that(Note.of<MyNotes>('personal').isPresent(), true),
Ensure.that(Note.of<MyNotes>('personal'), equals({ name: 'Alice', age: 27 })),
// using a QuestionAdapter
Ensure.that(Note.of<MyNotes>('personal').name, equals('Alice')),
Ensure.that(Note.of<MyNotes>('personal').age, isGreaterThan(18)),
)
The problem we’re trying to solve
The problem arises when the notes contain multiple TypeScript types.
For example, if MyNotes were to be defined as follows:
interface AuthDetails {
username: string;
password: string;
}
interface PersonalDetails {
name: string;
age: number;
}
interface MyNotes {
auth?: AuthDetails;
personal: PersonalDetails;
}
We will no longer be able to access Note.of<MyNotes>('personal').name and the following code will generate a type error: TS2339: Property 'name' does not exist on type 'QuestionAdapter '
Ensure.that(Note.of<MyNotes>('personal').name, equals('Alice')),
This is because of TypeScript’s lack of support for partial type argument inference (see https://github.com/microsoft/TypeScript/issues/26242 and https://github.com/microsoft/TypeScript/issues/10571), which means that
Note.of<MyNotes>('personal')
is interpreted as QuestionAdapter<AuthDetails | PersonalDetails>, and since name is not present in type AuthDetails we get the type error.
How we can solve it
Option 1: explicitly property name
One way to solve this problem would be to provide an explicit name of the property we’re planning to retrieve when we specify the interface describing the notes themselves:
class Notes {
static of<Notes extends Record<string, any>, Key extends keyof Notes>(subject: Key): QuestionAdapter<Notes[Key]> {
// ...
}
}
Advantages:
- Makes the Notepad more type-safe and allows for type-specific assertions and using
QuestionAdapterto access properties and methods of the underlying type, e.g.Note.of<MyNotes, 'personal'>('personal').name.toLocaleLowerCase()
Disadvantages:
- The API doesn’t look that great
Note.of<MyNotes, 'personal'>('personal').name🧐 - Doesn’t support dynamic subjects, e.g.
Note.of<MyNotes, ???>('my_note_' + test_case_name)
Option 2: make the whole notepad accessible
Another approach would be to separate the retrieval of the notepad or one of its “sections” from retrieving the specific note.
For example, while the below factory methods allow us to create a Notepad:
Notepad.empty<MyNotes>() : Notepad<MyNotes>
Notepad.with<MyNotes>({ /* */ }) : Notepad<MyNotes>
we could have a factory method that retrieves the notepad associated with a given actor:
Notepad.notes<MyNotes>() : QuestionAdapter<MyNotes>
Since the above method returns a QuestionAdapter<MyNotes> we could then easily access specific notes:
Ensure.that(Notepad.notes<MyNotes>().personal.name, equals('Alice')),
Ensure.that(Notepad.notes<MyNotes>()['personal']name, equals('Alice')),
Ensure.that(Notepad.notes<MyNotes>()[`something-${ 'dynamic' }`].name, equals('Alice')) // returns `any`
Advantages:
- makes retrieval of notes super easy
- leverages
QuestionAdapter - allows for the name of the note to be determined dynamically to a degree
Disadvantages:
- doesn’t allow for the name of the note to be determined based on the value of another
Question - I’m not sure what the API responsible for recording the notes should be 🤔
Recording notes
We could potentially have QuestionAdapter add .set(value: T): QuestionAdapter<T> the same way it adds .isPresent(): Question<Promise<boolean>>.
This way we’d have:
Notepad.notes<MyNotes>().personal.namereturnsQuestionAdapter<string>- ~
Notepad.notes<MyNotes>().personal.name.set('Alex')also returnsQuestionAdapter<string>(not sure if this one’s doable…)~ - it isn’t
or perhaps better:
Notepad.notes<MyNotes>().personal.set('name', 'Alex')returnsQuestionAdapter<PersonalDetails>Notepad.notes<MyNotes>().set('personal', { name: 'Alex', age: 34 })returnsQuestionAdapter<MyNotes>
Advantages:
- Makes the API reasonably intuitive
- The setter could support
Answerable<string>, so would work with dynamic values - Opens the doors to supporting default values, e.g.
Notepad.notes<MyNotes>().personal.name.orElse('Alex'), which could be useful on its own
Disadvantages:
- changing
QuestionAdapterwould affect every otherQuestionAdapter; this may or may not be desirable. - potentially tricky to implement; consider:
Notepad.empty() // so initial state is {}
Notepad.notes().personal.set('name', 'Bob') // should this throw?
Notepad.notes().personal.name.set('length', 2) // overriding a property of a string? uh.
What about orElse()? Should it just return the alternative?
Issue Analytics
- State:
- Created a year ago
- Comments:6 (2 by maintainers)

Top Related StackOverflow Question
@jan-molak
I was able to update my business project on Friday to rc17. There I’m still using empty and untyped notepads with the old “subject” style. This went well.
As I’m now out of office for a week, I fiddled around with the notes specs this morning and tried to implement something like:
This did not work out with rc17, but meanwhile you’ve released rc18 and I got this working - at least in the tests of the
corepackage.I’ve to look how this would apply to our real world project when I’m back in office next week.
As I wrote before, this notes thing may help to understand the difference between different actors. In the most of the Serenity/JS examples an actor just needs the
AbilitytoBrowseTheWebor toCallAnApiusing an url. So there was no need to have different actors - or at least it was a bit abstract, why one should use different actors. But giving them different types of notepads makes them more distinguishable.I have to think about that…
Use Case No. 2
This would be to add authentication to our shopping list. Users have to provide their credentials before they can use the shopping list. Initial data of the notepad shall be used. All other things are the same as in use case no. 1
Use Case No. 3
Our app now shall be tested with different users - so different sets of credentials.
Use Case No. 4
Our app gets an update. Users can now order the items in their list. They get a (not predictable) order number. This order number must be entered again at the next page for confirmation (so we have to take a note of it)
Writing down this use cases helped me to figure out my concerns about the notepad:
One concern ist about the mixup of typed and untyped notes in the same notepad, as well, as the mixup of initial / not initial data. But I think, that’s something you covered?
The second concern is the ability to scale, when application grows. There may be a lot of different types needed in different places of the application. So you would spin up all this in the
actors object.So maybe the second concern is not about the notepad at all and thats why it’s difficult for me to explain. It’s probably about what you mention here.
Actually, I never thought about an
Actoras kind of DI container. For now, I still init theActoronce in my whole test suite (given the possibility, you can init it in the wdio.conf).Maybe the question (or the concern) is about the lifetime of a notepad and the lifetime of an actor.
The nature of the “first steps in Serenity/JS examples” is, that they cover straight forward use cases. Maybe some good practices for growing test suites can help.