Skip to content

Return a Future from expect(). #529

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 2, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
## 0.12.18+2
## 0.12.19

* `expect()` now returns a Future for the asynchronous matchers `completes`,
`completion()`, `throws*()`, and `prints()`.

* Automatically configure the [`term_glyph`][term_glyph] package to use ASCII
glyphs when the test runner is running on Windows.
Expand Down
56 changes: 56 additions & 0 deletions lib/src/frontend/async_matcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';

import 'package:matcher/matcher.dart';

import '../backend/invoker.dart';
import 'expect.dart';

/// A matcher that does asynchronous computation.
///
/// Rather than implementing [matches], subclasses implement [matchAsync].
/// [AsyncMatcher.matches] ensures that the test doesn't complete until the
/// returned future completes, and [expect] returns a future that completes when
/// the returned future completes so that tests can wait for it.
abstract class AsyncMatcher extends Matcher {
const AsyncMatcher();

/// Returns `null` if this matches [item], or a [String] description of the
/// failure if it doesn't match.
///
/// This can return a [Future] or a synchronous value. If it returns a
/// [Future], neither [expect] nor the test will complete until that [Future]
/// completes.
///
/// If this returns a [String] synchronously, [expect] will synchronously
/// throw a [TestFailure] and [matches] will synchronusly return `false`.
/*FutureOr<String>*/ matchAsync(item);

bool matches(item, Map matchState) {
var result = matchAsync(item);
expect(result, anyOf([
equals(null),
new isInstanceOf<Future>(),
new isInstanceOf<String>()
]), reason: "matchAsync() may only return a String, a Future, or null.");

if (result is Future) {
Invoker.current.addOutstandingCallback();
result.then((realResult) {
if (realResult != null) fail(formatFailure(this, item, realResult));
Invoker.current.removeOutstandingCallback();
});
} else if (result is String) {
matchState[this] = result;
}

return true;
}

Description describeMismatch(
item, Description description, Map matchState, bool verbose) =>
new StringDescription(matchState[this]);
}
84 changes: 62 additions & 22 deletions lib/src/frontend/expect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';

import 'package:matcher/matcher.dart';

import '../backend/closed_exception.dart';
import '../backend/invoker.dart';
import '../utils.dart';
import 'async_matcher.dart';

/// An exception thrown when a test assertion fails.
class TestFailure {
Expand All @@ -18,6 +22,7 @@ class TestFailure {

/// The type used for functions that can be used to build up error reports
/// upon failures in [expect].
@Deprecated("Will be removed in 1.0.0.")
typedef String ErrorFormatter(
actual, Matcher matcher, String reason, Map matchState, bool verbose);

Expand All @@ -43,8 +48,26 @@ typedef String ErrorFormatter(
/// In some cases extra diagnostic info can be produced on failure (for
/// example, stack traces on mismatched exceptions). To enable these,
/// [verbose] should be specified as `true`.
void expect(actual, matcher,
{String reason, skip, bool verbose: false, ErrorFormatter formatter}) {
///
/// Returns a [Future] that completes when the matcher is finished running. For
/// the [completes] and [completion] matchers, as well as [throwsA] and related
/// matchers when they're matched against a [Future], this completes when the
/// matched future completes. For the [prints] matcher, it completes when the
/// future returned by the callback completes. Otherwise, it completes
/// immediately.
Future expect(actual, matcher,
{String reason,
skip,
bool verbose: false,
@Deprecated("Will be removed in 1.0.0.") ErrorFormatter formatter}) {
formatter ??= (actual, matcher, reason, matchState, verbose) {
var mismatchDescription = new StringDescription();
matcher.describeMismatch(actual, mismatchDescription, matchState, verbose);

return formatFailure(matcher, actual, mismatchDescription.toString(),
reason: reason);
};

if (Invoker.current == null) {
throw new StateError("expect() may only be called within a test.");
}
Expand All @@ -68,38 +91,55 @@ void expect(actual, matcher,
}

Invoker.current.skip(message);
return;
return new Future.value();
}

if (matcher is AsyncMatcher) {
// Avoid async/await so that expect() throws synchronously when possible.
var result = matcher.matchAsync(actual);
expect(result, anyOf([
equals(null),
new isInstanceOf<Future>(),
new isInstanceOf<String>()
]), reason: "matchAsync() may only return a String, a Future, or null.");

if (result is String) {
fail(formatFailure(matcher, actual, result, reason: reason));
} else if (result is Future) {
Invoker.current.addOutstandingCallback();
return result.then((realResult) {
if (realResult == null) return;
fail(formatFailure(matcher, actual, realResult, reason: reason));
}).whenComplete(() {
// Always remove this, in case the failure is caught and handled
// gracefully.
Invoker.current.removeOutstandingCallback();
});
}

return new Future.value();
}

var matchState = {};
try {
if (matcher.matches(actual, matchState)) return;
if (matcher.matches(actual, matchState)) return new Future.value();
} catch (e, trace) {
if (reason == null) {
reason = '${(e is String) ? e : e.toString()} at $trace';
}
reason ??= '$e at $trace';
}
if (formatter == null) formatter = _defaultFailFormatter;
fail(formatter(actual, matcher, reason, matchState, verbose));
return new Future.value();
}

/// Convenience method for throwing a new [TestFailure] with the provided
/// [message].
void fail(String message) => throw new TestFailure(message);

// The default error formatter.
String _defaultFailFormatter(
actual, Matcher matcher, String reason, Map matchState, bool verbose) {
var description = new StringDescription();
description.add('Expected: ').addDescriptionOf(matcher).add('\n');
description.add(' Actual: ').addDescriptionOf(actual).add('\n');

var mismatchDescription = new StringDescription();
matcher.describeMismatch(actual, mismatchDescription, matchState, verbose);

if (mismatchDescription.length > 0) {
description.add(' Which: ${mismatchDescription}\n');
}
if (reason != null) description.add(reason).add('\n');
return description.toString();
String formatFailure(Matcher expected, actual, String which, {String reason}) {
var buffer = new StringBuffer();
buffer.writeln(indent(prettyPrint(expected), first: 'Expected: '));
buffer.writeln(indent(prettyPrint(actual), first: ' Actual: '));
if (which.isNotEmpty) buffer.writeln(indent(which, first: ' Which: '));
if (reason != null) buffer.writeln(reason);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you want to indent the "reason" line too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the extra whitespace is just to visually verify that the first prefixes are all the same width.

return buffer.toString();
}
41 changes: 31 additions & 10 deletions lib/src/frontend/future_matchers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import 'dart:async';

import 'package:matcher/matcher.dart';

import '../backend/invoker.dart';
import '../utils.dart';
import 'async_matcher.dart';
import 'expect.dart';

/// Matches a [Future] that completes successfully with a value.
Expand All @@ -17,6 +18,9 @@ import 'expect.dart';
///
/// To test that a Future completes with an exception, you can use [throws] and
/// [throwsA].
///
/// This returns an [AsyncMatcher], so [expect] won't complete until the matched
/// future does.
final Matcher completes = const _Completes(null);

/// Matches a [Future] that completes succesfully with a value that matches
Expand All @@ -30,24 +34,41 @@ final Matcher completes = const _Completes(null);
/// [throwsA].
///
/// The [description] parameter is deprecated and shouldn't be used.
///
/// This returns an [AsyncMatcher], so [expect] won't complete until the matched
/// future does.
Matcher completion(matcher, [@deprecated String description]) =>
new _Completes(wrapMatcher(matcher));

class _Completes extends Matcher {
class _Completes extends AsyncMatcher {
final Matcher _matcher;

const _Completes(this._matcher);

bool matches(item, Map matchState) {
if (item is! Future) return false;
Invoker.current.addOutstandingCallback();
// Avoid async/await so we synchronously start listening to [item].
/*FutureOr<String>*/ matchAsync(item) {
if (item is! Future) return "was not a Future";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this string here but null below?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A string means failure, null means success. It's a failure if the item isn't the right type, but if _matcher is null that just means that this came from completes and doesn't care about the actual completion value.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha. can you add that to the comments?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's in the AsyncMatcher.matchAsync() docs—is it really worth duplicating in the subclasses?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh. right. I can read. Carry on then!


item.then((value) {
if (_matcher != null) expect(value, _matcher);
Invoker.current.removeOutstandingCallback();
});
return item.then((value) async {
if (_matcher == null) return null;

return true;
String result;
if (_matcher is AsyncMatcher) {
result = await (_matcher as AsyncMatcher).matchAsync(value);
if (result == null) return null;
} else {
var matchState = {};
if (_matcher.matches(value, matchState)) return null;
result = _matcher
.describeMismatch(value, new StringDescription(), matchState, false)
.toString();
}

var buffer = new StringBuffer();
buffer.writeln(indent(prettyPrint(value), first: 'emitted '));
if (result.isNotEmpty) buffer.writeln(indent(result, first: ' which '));
return buffer.toString().trimRight();
});
}

Description describe(Description description) {
Expand Down
64 changes: 27 additions & 37 deletions lib/src/frontend/prints_matcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import 'dart:async';

import 'package:matcher/matcher.dart';

import '../utils.dart';
import 'async_matcher.dart';
import 'expect.dart';
import 'future_matchers.dart';

/// Matches a [Function] that prints text that matches [matcher].
///
Expand All @@ -17,63 +18,52 @@ import 'future_matchers.dart';
/// the function (using [Zone] scoping) until that Future completes is matched.
///
/// This only tracks text printed using the [print] function.
///
/// This returns an [AsyncMatcher], so [expect] won't complete until the matched
/// function does.
Matcher prints(matcher) => new _Prints(wrapMatcher(matcher));

class _Prints extends Matcher {
class _Prints extends AsyncMatcher {
final Matcher _matcher;

_Prints(this._matcher);

bool matches(item, Map matchState) {
if (item is! Function) return false;
// Avoid async/await so we synchronously fail if the function is
// synchronous.
/*FutureOr<String>*/ matchAsync(item) {
if (item is! Function) return "was not a Function";

var buffer = new StringBuffer();
var result = runZoned(item,
zoneSpecification: new ZoneSpecification(print: (_, __, ____, line) {
buffer.writeln(line);
}));

if (result is! Future) {
var actual = buffer.toString();
matchState['prints.actual'] = actual;
return _matcher.matches(actual, matchState);
}

return completes.matches(result.then((_) {
// Re-run expect() so we get the same formatting as we would without being
// asynchronous.
expect(() {
var actual = buffer.toString();
if (actual.isEmpty) return;

// Strip off the final newline because [print] will re-add it.
actual = actual.substring(0, actual.length - 1);
print(actual);
}, this);
}), matchState);
return result is Future
? result.then((_) => _check(buffer.toString()))
: _check(buffer.toString());
}

Description describe(Description description) =>
description.add('prints ').addDescriptionOf(_matcher);

Description describeMismatch(
item, Description description, Map matchState, bool verbose) {
var actual = matchState.remove('prints.actual');
if (actual == null) return description;
if (actual.isEmpty) return description.add("printed nothing.");
/// Verifies that [actual] matches [_matcher] and returns a [String]
/// description of the failure if it doesn't.
String _check(String actual) {
var matchState = {};
if (_matcher.matches(actual, matchState)) return null;

description.add('printed ').addDescriptionOf(actual);

// Create a new description for the matcher because at least
// [_StringEqualsMatcher] replaces the previous contents of the description.
var innerMismatch = _matcher
.describeMismatch(actual, new StringDescription(), matchState, verbose)
var result = _matcher
.describeMismatch(actual, new StringDescription(), matchState, false)
.toString();

if (innerMismatch.isNotEmpty) {
description.add('\n Which: ').add(innerMismatch.toString());
var buffer = new StringBuffer();
if (actual.isEmpty) {
buffer.writeln('printed nothing');
} else {
buffer.writeln(indent(prettyPrint(actual), first: 'printed '));
}

return description;
if (result.isNotEmpty) buffer.writeln(indent(result, first: ' which '));
return buffer.toString().trimRight();
}
}
Loading