/
Task.ts
230 lines (223 loc) · 10.5 KB
/
Task.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import { ImplementationPendingError } from '../errors';
import type { PerformsActivities } from './activities';
import { Activity } from './Activity';
/**
* **Tasks** model **{@apilink Activity|sequences of activities}**
* and help you capture meaningful steps of an {@apilink Actor|actor} workflow
* in your domain.
*
* Typically, tasks correspond to higher-level, business domain-specific activities
* like to `BookAPlaneTicket`, `PlaceATrade`, `TransferFunds`, and so on.
* However, higher-level tasks can and should be composed of lower-level tasks.
* For example, a task to `SignUp` could be composed of tasks to `ProvideUsername` and `ProvidePassword`.
*
* The lowest-level tasks in your abstraction hierarchy should be composed of {@apilink Interaction|interactions}.
* For example, a low-level task to `ProvideUsername` could be composed of an interaction to {@apilink Enter} the value
* into a form field and {@apilink Press} the {@apilink Key.Enter}.
*
* Tasks are the core building block of the [Screenplay Pattern](/handbook/design/screenplay-pattern),
* along with {@apilink Actor|Actors}, {@apilink Ability|Abilities}, {@apilink Interaction|Interactions}, and {@apilink Question|Questions}.
*
* ![Screenplay Pattern](/images/design/serenity-js-screenplay-pattern.png)
*
* Learn more about:
* - [User-Centred Design: How a 50 year old technique became the key to scalable test automation](https://janmolak.com/user-centred-design-how-a-50-year-old-technique-became-the-key-to-scalable-test-automation-66a658a36555)
* - {@apilink Actor|Actors}
* - {@apilink Activity|Activities}
* - {@apilink Interaction|Interactions}
*
* ## Defining a task
*
* ```ts
* import { Answerable, Task, d } from '@serenity-js/core'
* import { By, Click, Enter, PageElement, Press, Key } from '@serenity-js/web'
*
* const SignIn = (username: Answerable<string>, password: Answerable<string>) =>
* Task.where(d`#actor signs is as ${ username }`,
* Enter.theValue(username).into(PageElement.located(By.id('username'))),
* Enter.theValue(password).into(PageElement.located(By.id('password'))),
* Press.the(Key.Enter),
* );
* ```
*
* ## Defining a not implemented task
*
* Note that calling {@apilink Task.where} method without providing the sequence of {@apilink Activity|activities}
* produces a Task that's marked as "pending" in the test report.
*
* This feature is useful when you want to quickly write down a task that will be needed in the scenario,
* but you're not yet sure what activities it will involve.
*
* ```ts
* import { Task } from '@serenity-js/core'
*
* const SignUp = () =>
* Task.where(`#actor signs up for a newsletter`) // no activities provided
* // => task marked as pending
* ```
*
* ## Composing activities into tasks
*
* The purpose of **tasks** is to help you capture domain vocabulary by **associating domain meaning** with a **sequence of activities**.
* From the implementation perspective, tasks help you give a **meaningful description** to such sequence
* and provide a way to **easily reuse activities across your code base**.
*
* :::tip Remember
* **Tasks** associate **domain meaning** with a sequence of **lower-level activities** and provide a mechanism for **code reuse**.
* :::
*
* For example, a task to _find a flight connection from London to New York_ could be modelled as a sequence of the following lower-level activities:
* - specify origin city of "London"
* - specify destination city of "New York"
*
* The easiest way to implement such task, and any custom Serenity/JS task for this matter, is to use the {@apilink Task.where} method to compose the lower-level activities:
*
* ```typescript
* import { Task } from '@serenity-js/core'
*
* const findFlight = (originCity: string, destinationCity: string) =>
* Task.where(`#actors finds a flight from ${ originCity } to ${ destinationCity }`, // task goal
* specifyOriginCity(originCity), // activities
* specifyDestinationCity(originCity),
* )
* ```
*
* Furthermore, if the actor was interacting with a web UI, a task to _specify origin city_ could again be composed of other activities:
* - click on the `origin airport` widget
* - enter city name of `London`
* - pick the first suggested airport from the list
*
* Conversely, a task to _specify destination city_ could be composed of:
* - click on the `destination airport` widget
* - enter city name of `New York`
* - pick the first suggested airport from the list
*
* Conveniently, [Serenity/JS modules](/handbook/about/architecture) provide low-level activities that
* allow actors to interact with the various interfaces of the system under test.
* For example, [Serenity/JS Web module](/api/web) ships with activities such as {@apilink Click} or {@apilink Enter},
* which we can incorporate into our task definitions just like any other activities:
*
* ```typescript
* import { Task } from '@serenity-js/core'
* import { Click, Enter, Key, Press } from '@serenity-js/web'
*
* import { FlightFinder } from './ui/flight-finder'
*
* const specifyOriginCity = (originCity: string) =>
* Task.where(`#actor specifies origin city of ${ originCity }`,
* Click.on(FlightFinder.originAirport),
* Enter.theValue(originCity).into(FlightFinder.originAirport),
* Press.the(Key.ArrowDown, Key.Enter).into(FlightFinder.originAirport),
* )
*
* const specifyDestinationCity = (destinationCity: string) =>
* Task.where(`#actor specifies destination city of ${ destinationCity }`,
* Click.on(FlightFinder.destinationAirport),
* Enter.theValue(destinationCity).into(FlightFinder.destinationAirport),
* Press.the(Key.ArrowDown, Key.Enter).into(FlightFinder.destinationAirport),
* )
* ```
*
* As you can already see, tasks to _specify origin city_ and _specify destination city_ are almost identical,
* save for the name of the widget and the text value the actor is supposed to enter.
* Serenity/JS **task-based code reuse model** means that we can clean up such duplicated implementation
* by **extracting a parameterised task**, in this case called `specifyCity`:
*
* ```typescript
* import { Task } from '@serenity-js/core'
* import { Click, Enter, Key, PageElement, Press } from '@serenity-js/web'
*
* import { FlightFinder } from './ui/flight-finder'
*
* const specifyOriginCity = (originCity: string) =>
* Task.where(`#actor specifies origin city of ${ originCity }`,
* specifyCity(originCity, FlightFinder.originAirport)
* )
*
* const specifyDestinationCity = (destinationCity: string) =>
* Task.where(`#actor specifies destination city of ${ destinationCity }`,
* specifyCity(destinationCity, FlightFinder.destinationAirport),
* )
*
* const specifyCity = (cityName: string, widget: PageElement) =>
* Task.where(`#actor specifies city of ${ cityName } in ${ widget }`,
* Click.on(widget),
* Enter.theValue(cityName).into(widget),
* Press.the(Key.ArrowDown, Key.Enter).into(widget),
* )
* ```
*
* As you work with Serenity/JS, you'll notice that the ideas of **functional decomposition**, so thinking of tasks as sequences of lower-level activities,
* as well as **functional composition**, so implementing reusable activities and composing them into higher-level tasks,
* are at the heart of the Screenplay Pattern. You'll also notice that the entire Serenity/JS framework does it best to help your team follow this approach.
*
* :::info The power of the Serenity/JS task-based code reuse model
* What makes the Serenity/JS task-based code reuse model so **powerful at scale** is the observation that:
* - for most software systems, a vast number of **diverse test scenarios** can be composed of a relatively **small number of high-level activities**
* - all **high-level activities** can be composed of a relatively **small number of lower-level activities**
* - in a reasonably consistently-designed software system, most lower-level activities tend to be similar and rather consistent across the various aspects of a given interface.
* In particular, **there are only so many ways** one can interact with a UI button or send an HTTP request to a web service.
*
* What this means in practice is that by investing your time in properly designing **relatively few reusable tasks**
* to test your system, you give your team a **significant productivity boost** and **leverage** when producing high-level test scenarios.
*
* On top of that, this design approach results not only in **simpler test scenarios** that reduce
* the [cognitive load](https://en.wikipedia.org/wiki/Cognitive_load) on the reader as they require them to process
* the scenario only one level of abstraction at the time.
* It also allows for the test to **take shortcuts** in well-defined points of the workflow - use a REST API request to create
* a test user account instead of going through the registration form.
* :::
*
* @group Screenplay Pattern
*/
export abstract class Task extends Activity {
/**
* A factory method that makes defining custom tasks more convenient.
*
* @param description
* A description to be used when reporting this task
*
* @param activities
* A sequence of lower-level activities that constitute this task
*/
static where(description: string, ...activities: Activity[]): Task {
return activities.length > 0
? new DynamicallyGeneratedTask(description, activities)
: new NotImplementedTask(description);
}
/**
* Instructs the provided {@apilink Actor} to perform this {@apilink Task}.
*
* @param {PerformsActivities} actor
*
* #### Learn more
* - {@apilink Actor}
* - {@apilink PerformsActivities}
* - {@apilink Activity}
*/
abstract performAs(actor: PerformsActivities): Promise<void>;
}
/**
* @package
*/
class DynamicallyGeneratedTask extends Task {
constructor(description: string, private activities: Activity[]) {
super(description, Task.callerLocation(4));
}
performAs(actor: PerformsActivities): Promise<void> {
return actor.attemptsTo(...this.activities);
}
}
/**
* @package
*/
class NotImplementedTask extends Task {
constructor(description: string) {
super(description, Task.callerLocation(4));
}
performAs(actor: PerformsActivities): Promise<void> {
return Promise.reject(
new ImplementationPendingError(`A task where "${ this.toString() }" has not been implemented yet`),
);
}
}