Skip to content
Permalink
Browse files
feat(assertions): Ensure reports the actual value if the expectation …
…is not met
  • Loading branch information
jan-molak committed Apr 26, 2019
1 parent 5e97d35 commit 4d00be383f3b3108fe9c1c51776cfce10c4b577c
Showing with 235 additions and 162 deletions.
  1. +55 −4 packages/assertions/spec/Ensure.spec.ts
  2. +13 −2 packages/assertions/src/Ensure.ts
  3. +5 −4 packages/core/spec/io/formatted.spec.ts
  4. +4 −152 packages/core/src/io/formatted.ts
  5. +158 −0 packages/core/src/io/inspected.ts
@@ -1,15 +1,23 @@
import 'mocha';
import { given } from 'mocha-testdata';

import { expect, stage } from '@integration/testing-tools';
import { Answerable, AnswersQuestions, AssertionError, LogicError, RuntimeError, TestCompromisedError } from '@serenity-js/core';
import { EventRecorder, expect, PickEvent, stage } from '@integration/testing-tools';
import { Actor, Answerable, AnswersQuestions, AssertionError, LogicError, Question, RuntimeError, Stage, TestCompromisedError } from '@serenity-js/core';
import { ArtifactGenerated } from '@serenity-js/core/lib/events';
import { Name } from '@serenity-js/core/lib/model';
import { given } from 'mocha-testdata';
import { Ensure, equals, Expectation, Outcome } from '../src';
import { isIdenticalTo, p, q } from './fixtures';

/** @test {Ensure} */
describe('Ensure', () => {

const Enrique = stage().theActorCalled('Enrique');
let theStage: Stage,
Enrique: Actor;

beforeEach(() => {
theStage = stage();
Enrique = theStage.theActorCalled('Enrique');
});

/** @test {Ensure.that} */
it('allows the actor to make an assertion', () => {
@@ -69,6 +77,49 @@ describe('Ensure', () => {
)).to.be.rejectedWith(LogicError, 'An Expectation should return an instance of an Outcome, not null');
});

given([{
description: 'tiny type',
actual: new Name('Bob'),
expected: 'Name(value=Bob)',
}, {
description: 'boolean',
actual: true,
expected: 'true',
}, {
description: 'string',
actual: 'name',
expected: `'name'`,
}, {
description: 'list',
actual: [{ name: 'Bob' }, { name: 'Alice' }],
expected: `[\n { name: 'Bob' },\n { name: 'Alice' }\n]`,
}, {
description: 'promise',
actual: Promise.resolve(true),
expected: `true`,
}, {
description: 'question',
actual: Question.about('some value', actor => 'value'),
expected: `'value'`,
}]).
/** @test {Ensure.that} */
it(`emits an artifact describing the actual value`, ({ actual, expected }) => {
const recorder = new EventRecorder();
theStage.assign(recorder);

return expect(Enrique.attemptsTo(
Ensure.that(actual, equals(null)), // we don't care about the expectation itself in this test
)).to.be.rejected.then(() =>

PickEvent.from(recorder.events)
.next(ArtifactGenerated, e => e.artifact.map(value => {
expect(value.contentType).to.equal('text/plain');
expect(value.data).to.equal(expected);
})),
);

});

describe('custom errors', () => {

it(`allows the actor to fail the flow with a custom RuntimeError, embedding the original error`, () => {
@@ -1,5 +1,7 @@
import { Answerable, AnswersQuestions, AssertionError, Interaction, LogicError, RuntimeError } from '@serenity-js/core';
import { Answerable, AnswersQuestions, AssertionError, CollectsArtifacts, Interaction, LogicError, RuntimeError, UsesAbilities } from '@serenity-js/core';
import { formatted } from '@serenity-js/core/lib/io';
import { inspected } from '@serenity-js/core/lib/io/inspected';
import { Artifact, Name, TextData } from '@serenity-js/core/lib/model';
import { match } from 'tiny-types';

import { Expectation } from './Expectation';
@@ -17,7 +19,7 @@ export class Ensure<Actual> extends Interaction {
super();
}

performAs(actor: AnswersQuestions): PromiseLike<void> {
performAs(actor: UsesAbilities & AnswersQuestions & CollectsArtifacts): PromiseLike<void> {
return Promise.all([
actor.answer(this.actual),
actor.answer(this.expectation),
@@ -26,6 +28,8 @@ export class Ensure<Actual> extends Interaction {
expectation(actual).then(outcome =>
match<Outcome<any, Actual>, void>(outcome)
.when(ExpectationNotMet, o => {
actor.collect(this.artifactFrom(actual), new Name(`Actual value`));

throw this.errorForOutcome(o);
})
.when(ExpectationMet, _ => void 0)
@@ -55,6 +59,13 @@ export class Ensure<Actual> extends Interaction {
outcome.actual,
);
}

private artifactFrom(actual: Actual): Artifact {
return TextData.fromJSON({
contentType: 'text/plain',
data: inspected(actual),
});
}
}

class EnsureOrFailWithCustomError<Actual> extends Ensure<Actual> {
@@ -1,5 +1,6 @@
import 'mocha';
import { given } from 'mocha-testdata';
import * as util from 'util';

import { formatted } from '../../src/io';
import { Question } from '../../src/screenplay';
@@ -11,7 +12,7 @@ describe ('`formatted` tag function', () => {
const
p = value => Promise.resolve(value),
q = value => Question.about(`the meaning of life`, actor => value),
i = value => ({ inspect: () => value }),
i = value => ({ [util.inspect.custom]: () => value }),
ts = value => ({ toString: () => value });

class SomeAttribute {}
@@ -24,10 +25,10 @@ describe ('`formatted` tag function', () => {
{ description: 'an object parameter', actual: formatted `${ { twitter: '@JanMolak'} }`, expected: "{ twitter: '@JanMolak' }" },
{ description: 'an empty array', actual: formatted `${ [] }`, expected: '[ ]' },
{ description: 'an array parameter', actual: formatted `${ [1, 2, '3'] }`, expected: "[ 1, 2, '3' ]" },
{ description: 'an array of params', actual: formatted `${ [ Promise.resolve(1), q('2') ] }`, expected: '[ a promised value, the meaning of life ]' },
{ description: 'an array of params', actual: formatted `${ [ Promise.resolve(1), q('2') ] }`, expected: '[ a Promise, the meaning of life ]' },
{ description: 'an object array parameter', actual: formatted `${ [{ name: 'Jan'}] }`, expected: "[ { name: 'Jan' } ]" },
{ description: 'a Date parameter', actual: formatted `${ new Date('Jan 27, 2019') }`, expected: '2019-01-27T00:00:00.000Z' },
{ description: 'a promised parameter', actual: formatted `${ p('something') }`, expected: 'a promised value' },
{ description: 'a promised parameter', actual: formatted `${ p('something') }`, expected: 'a Promise' },
{ description: 'a question', actual: formatted `${ q('value') }`, expected: 'the meaning of life' },
{ description: 'an inspectable object', actual: formatted `${ i('result') }`, expected: 'result' },
{ description: 'an "toStringable" object', actual: formatted `${ ts('result') }`, expected: 'result' },
@@ -40,6 +41,6 @@ describe ('`formatted` tag function', () => {
/** @test {formatted} */
it('produces a human-readable description when given a template with multiple parameters', () => {
expect(formatted `Hello, ${ 'World' }! I've got ${ p('result') } for you!`)
.to.equal("Hello, 'World'! I've got a promised value for you!");
.to.equal("Hello, 'World'! I've got a Promise for you!");
});
});
@@ -1,6 +1,5 @@
import { inspect } from 'util';
import { Answerable } from '../screenplay/Answerable';
import { Question } from '../screenplay/Question';
import { inspected } from './inspected';

/**
* @desc
@@ -12,159 +11,12 @@ import { Question } from '../screenplay/Question';
* @param {Array<Answerable<any>>} placeholders
*/
export function formatted(templates: TemplateStringsArray, ...placeholders: Array<Answerable<any>>) {
const compacted = (multiline: string) => multiline.replace(/\r?\n/g, ' ').replace(/\s+/g, ' ');

return templates
.map((template, i) => i < placeholders.length
? [ template, descriptionOf(placeholders[i]) ]
? [ template, compacted(inspected(placeholders[i])) ]
: [ template ])
.reduce((acc, tuple) => acc.concat(tuple))
.join('');
}

/**
* @desc
* Provides a human-readable and sync description of the {@link Answerable<T>}
*
* @package
* @param value
*/
function descriptionOf(value: Answerable<any>): string {
if (! isDefined(value)) {
return inspect(value);
}

if (Array.isArray(value)) {
return `[ ${ value.map(item => descriptionOf(item)).join(', ') } ]`.replace(/\s+/, ' ');
}

if (isAPromise(value)) {
return `a promised value`;
}

if (Question.isAQuestion(value)) {
return value.toString();
}

if (isADate(value)) {
return value.toISOString();
}

if (hasItsOwnToString(value)) {
return value.toString();
}

if (isInspectable(value)) {
return value.inspect();
}

if (isANamedFunction(value)) {
return `${ value.name } property`;
}

return inspect(value, { breakLength: Infinity, compact: true, sorted: false }).replace(/\r?\n/g, '');
}

/**
* @desc
* Checks if the value is defined
*
* @private
* @param {Answerable<any>} v
*/
function isDefined(v: Answerable<any>) {
return !! v;
}

/**
* @desc
* Checks if the value defines its own `toString` method
*
* @private
* @param {Answerable<any>} v
*/
function hasItsOwnToString(v: Answerable<any>): v is { toString: () => string } {
return typeof v === 'object'
&& !! (v as any).toString
&& typeof (v as any).toString === 'function'
&& ! isNative((v as any).toString);
}

/**
* @desc
* Checks if the value defines its own `inspect` method
*
* @private
* @param {Answerable<any>} v
*/
function isInspectable(v: Answerable<any>): v is { inspect: () => string } {
return !! (v as any).inspect && typeof (v as any).inspect === 'function';
}

/**
* @desc
* Checks if the value is a {@link Date}
*
* @private
* @param {Answerable<any>} v
*/
function isADate(v: Answerable<any>): v is Date {
return v instanceof Date;
}

/**
* @desc
* Checks if the value is a {@link Promise}
*
* @private
* @param {Answerable<any>} v
*/
function isAPromise<T>(v: Answerable<T>): v is Promise<T> {
return !! (v as any).then;
}

/**
* @desc
* Checks if the value is a named {@link Function}
*
* @private
* @param {Answerable<any>} v
*/
function isANamedFunction<T>(v: any): v is { name: string } {
return {}.toString.call(v) === '[object Function]' && (v as any).name !== '';
}

/**
* https://davidwalsh.name/detect-native-function
* @param {any} v
*/
function isNative(v: any): v is Function { // tslint:disable-line:ban-types

const
toString = Object.prototype.toString, // Used to resolve the internal `[[Class]]` of values
fnToString = Function.prototype.toString, // Used to resolve the decompiled source of functions
hostConstructor = /^\[object .+?Constructor\]$/; // Used to detect host constructors (Safari > 4; really typed array specific)

// Compile a regexp using a common native method as a template.
// We chose `Object#toString` because there's a good chance it is not being mucked with.
const nativeFnTemplate = RegExp(
'^' +
// Coerce `Object#toString` to a string
String(toString)
// Escape any special regexp characters
.replace(/[.*+?^${}()|[\]\/\\]/g, '\\$&')
// Replace mentions of `toString` with `.*?` to keep the template generic.
// Replace thing like `for ...` to support environments like Rhino which add extra info
// such as method arity.
.replace(/toString|(function).*?(?=\\\()| for .+?(?=\\\])/g, '$1.*?') +
'$',
);

const type = typeof v;
return type === 'function'
// Use `Function#toString` to bypass the value's own `toString` method
// and avoid being faked out.
? nativeFnTemplate.test(fnToString.call(v))
// Fallback to a host object check because some environments will represent
// things like typed arrays as DOM methods which may not conform to the
// normal native pattern.
: (v && type === 'object' && hostConstructor.test(toString.call(v))) || false;
}

0 comments on commit 4d00be3

Please sign in to comment.