UNPKG

@serenity-js/core

Version:

The core Serenity/JS framework, providing the Screenplay Pattern interfaces, as well as the test reporting and integration infrastructure

280 lines (258 loc) 12.4 kB
import type { JSONValue } from 'tiny-types'; import { asyncMap, d } from '../../io'; import { ExpectationDetails, ExpectationMet, ExpectationNotMet } from '../'; import type { Answerable, AnswersQuestions, QuestionAdapter} from '../index'; import { the } from '../index'; import { Question } from '../Question'; import { Describable } from '../questions'; import type { ExpectationOutcome } from './expectations'; /** * @group Expectations */ export type Predicate<Actual> = (actor: AnswersQuestions, actual: Answerable<Actual>) => Promise<ExpectationOutcome> | ExpectationOutcome; // eslint-disable-line @typescript-eslint/indent type AnswerableArguments<Arguments extends Array<unknown>> = { [Index in keyof Arguments]: Answerable<Arguments[Index]> }; /** * Defines an expectation to be used with [`Wait.until`](https://serenity-js.org/api/core/class/Wait/#until), * [`Check.whether`](https://serenity-js.org/api/core/class/Check/#whether), * [`Ensure.that`](https://serenity-js.org/api/assertions/class/Ensure/#that) * and as part of the Page Element Query Language with [`PageElements.where`](https://serenity-js.org/api/web/class/PageElements/#where) * and [`List.where`](https://serenity-js.org/api/core/class/List/#where). * * @group Expectations */ export class Expectation<Actual> extends Describable { /** * A factory method to that makes defining custom [expectations](https://serenity-js.org/api/core/class/Expectation/) easier * * #### Defining a custom expectation * * ```ts * import { Expectation } from '@serenity-js/core' * import { PageElement } from '@serenity-js/web' * * const isEmpty = Expectation.define( * 'isEmpty', // name of the expectation function to be used when producing an AssertionError * 'become empty', // human-readable description of the relationship between expected and actual values * async (actual: PageElement) => { * const value = await actual.value(); * return value.length === 0; * } * ) * ``` * * #### Using a custom expectation in an assertion * * ```ts * import { Ensure } from '@serenity-js/assertions' * import { actorCalled } from '@serenity-js/core' * import { By, Clear, PageElement } from '@serenity-js/web' * * const nameField = () => * PageElement.located(By.css('[data-test-id="name"]')).describedAs('name field'); * * await actorCalled('Izzy').attemptsTo( * Clear.the(nameField()), * Ensure.that(nameField(), isEmpty()) * ) * ``` * * #### Using a custom expectation in a control flow statement * * ```ts * import { not } from '@serenity-js/assertions' * import { actorCalled, Check, Duration, Wait } from '@serenity-js/core' * import { By, PageElement } from '@serenity-js/web' * * const nameField = () => * PageElement.located(By.css('[data-test-id="name"]')).describedAs('name field'); * * await actorCalled('Izzy').attemptsTo( * Check.whether(nameField(), isEmpty()) * .andIfSo( * Enter.theValue(actorInTheSpotlight().name).into(nameField()), * ), * ) * ``` * * #### Using a custom expectation in a synchronisation statement * * ```ts * import { not } from '@serenity-js/assertions' * import { actorCalled, Duration, Wait } from '@serenity-js/core' * import { By, PageElement } from '@serenity-js/web' * * const nameField = () => * PageElement.located(By.css('[data-test-id="name"]')).describedAs('name field'); * * await actorCalled('Izzy').attemptsTo( * Enter.theValue(actorInTheSpotlight().name).into(nameField()), * * Wait.upTo(Duration.ofSeconds(2)) * .until(nameField(), not(isEmpty())), * ) * ``` * * #### Learn more * - [`Ensure`](https://serenity-js.org/api/assertions/class/Ensure/) * - [`Check`](https://serenity-js.org/api/core/class/Check/) * - [`Wait`](https://serenity-js.org/api/core/class/Wait/) * * @param functionName * Name of the expectation function to be used when producing an [`AssertionError`](https://serenity-js.org/api/core/class/AssertionError/) * * @param relationship * Human-readable description of the relationship between the `expected` and the `actual` values. * Used when reporting [activities](https://serenity-js.org/api/core/class/Activity/) performed by an [actor](https://serenity-js.org/api/core/class/Actor/) * * @param predicate */ static define<Actual_Type, PredicateArguments extends Array<unknown>>( functionName: string, relationship: ((...answerableArguments: AnswerableArguments<PredicateArguments>) => Answerable<string>) | Answerable<string>, predicate: (actual: Actual_Type, ...predicateArguments: PredicateArguments) => Promise<boolean> | boolean, ): (...answerableArguments: AnswerableArguments<PredicateArguments>) => Expectation<Actual_Type> { return Object.defineProperty(function(...answerableArguments: AnswerableArguments<PredicateArguments>): Expectation<Actual_Type> { const description: Answerable<string> = typeof relationship === 'function' ? relationship(...answerableArguments) : (answerableArguments.length === 1 ? the`${ { toString: () => relationship } } ${ answerableArguments[0] }` : relationship); return new Expectation<Actual_Type>( functionName, description, async (actor: AnswersQuestions, actualValue: Answerable<Actual_Type>): Promise<ExpectationOutcome> => { const predicateArguments = await asyncMap(answerableArguments, answerableArgument => actor.answer(answerableArgument as Answerable<JSONValue>) ); const actual = await actor.answer(actualValue); const result = await predicate(actual, ...predicateArguments as PredicateArguments); const descriptionText = await actor.answer(description); const expectationDetails = ExpectationDetails.of(functionName, ...predicateArguments); const expected = predicateArguments.length > 0 ? predicateArguments[0] : true; // the only parameter-less expectations are boolean ones like `isPresent`, `isActive`, etc. return result ? new ExpectationMet(descriptionText, expectationDetails, expected, actual) : new ExpectationNotMet(descriptionText, expectationDetails, expected, actual); } ) }, 'name', {value: functionName, writable: false}); } /** * Used to define a simple [`Expectation`](https://serenity-js.org/api/core/class/Expectation/) * * #### Simple parameterised expectation * * ```ts * import { actorCalled, Expectation } from '@serenity-js/core' * import { Ensure } from '@serenity-js/assertions' * * function isDivisibleBy(expected: Answerable<number>): Expectation<number> { * return Expectation.thatActualShould<number, number>('have value divisible by', expected) * .soThat((actualValue, expectedValue) => actualValue % expectedValue === 0); * } * * await actorCalled('Erica').attemptsTo( * Ensure.that(4, isDivisibleBy(2)), * ) * ``` * * @param relationshipName * Name of the relationship between the `actual` and the `expected`. Use format `have value <adjective>` * so that the description works in both positive and negative contexts, e.g. `Waited until 5 does have value greater than 2`, * `Expected 5 to not have value greater than 2`. * * @param expectedValue */ static thatActualShould<Expected_Type, Actual_Type>(relationshipName: string, expectedValue?: Answerable<Expected_Type>): { soThat: (simplifiedPredicate: (actualValue: Actual_Type, expectedValue: Expected_Type) => Promise<boolean> | boolean) => Expectation<Actual_Type>, } { return ({ soThat: (simplifiedPredicate: (actualValue: Actual_Type, expectedValue: Expected_Type) => Promise<boolean> | boolean): Expectation<Actual_Type> => { const message = relationshipName + ' ' + d`${expectedValue}`; return new Expectation<Actual_Type>( 'unknown', message, async (actor: AnswersQuestions, actualValue: Answerable<Actual_Type>): Promise<ExpectationOutcome> => { const expected = await actor.answer(expectedValue); const actual = await actor.answer(actualValue); const result = await simplifiedPredicate(actual, expected); const expectationDetails = ExpectationDetails.of('unknown'); return result ? new ExpectationMet(message, expectationDetails, expected, actual) : new ExpectationNotMet(message, expectationDetails, expected, actual); } ); }, }); } /** * Used to compose [expectations](https://serenity-js.org/api/core/class/Expectation/). * * #### Composing [expectations](https://serenity-js.org/api/core/class/Expectation/) * * ```ts * import { actorCalled, Expectation } from '@serenity-js/core' * import { Ensure, and, or, isGreaterThan, isLessThan, equals } from '@serenity-js/assertions' * * function isWithin(lowerBound: number, upperBound: number) { * return Expectation * .to(`have value within ${ lowerBound } and ${ upperBound }`) * .soThatActual( * and( * or(isGreaterThan(lowerBound), equals(lowerBound)), * or(isLessThan(upperBound), equals(upperBound)), * ) * ) * } * * await actorCalled('Erica').attemptsTo( * Ensure.that(5, isWithin(3, 6)), * ) * ``` * * @param relationshipName * Name of the relationship between the `actual` and the `expected`. Use format `have value <adjective>` * so that the description works in both positive and negative contexts, e.g. `Waited until 5 does have value greater than 2`, * `Expected 5 to not have value greater than 2`. */ static to<Actual_Type>(relationshipName: string): { soThatActual: (expectation: Expectation<Actual_Type>) => Expectation<Actual_Type>, } { return { soThatActual: (expectation: Expectation<Actual_Type>): Expectation<Actual_Type> => { return new Expectation<Actual_Type>( 'unknown', relationshipName, async (actor: AnswersQuestions, actualValue: Answerable<Actual_Type>): Promise<ExpectationOutcome> => { return await actor.answer(expectation.isMetFor(actualValue)); } ); }, }; } protected constructor( private readonly functionName: string, description: Answerable<string>, private readonly predicate: Predicate<Actual> ) { super(description); } /** * Returns a [`QuestionAdapter`](https://serenity-js.org/api/core/#QuestionAdapter) that resolves to [`ExpectationOutcome`](https://serenity-js.org/api/core/class/ExpectationOutcome/) * indicating that the [expectation was met](https://serenity-js.org/api/core/class/ExpectationMet/) * or that the [expectation was not met](https://serenity-js.org/api/core/class/ExpectationNotMet/) * * @param actual */ isMetFor(actual: Answerable<Actual>): QuestionAdapter<ExpectationOutcome> { return Question.about(this.getDescription(), actor => this.predicate(actor, actual)); } /** * @inheritDoc */ describedAs(description: Answerable<string>): this { super.setDescription(description); return this; } }