Skip to content

Commit 8ab184b

Browse files
committed
Make label arguments take callbacks
The main benefit this brings is it brings more alignment with the `clause` arguments for `expect` calls. The docs will be able to focus on the difference in how the value is use (preceding "that" in the case of labels, standing on its own in a list in the case of clauses) and can use a consistent description for how it is passed. A secondary benefit is that it allows multiline labels and avoid workaround like joining with `r'\n'`. A final benefit is that it saves some unnecessary String formatting since the callback isn't called if no expectations fail on the Subject, or when used as a soft check where the failure details are ignored. - Make the `label` arguments to `nest` and `nestAsync`, and the _label field in `_TestContext` an `Iterable<String> Function()`. - Wrap strings that had been passed to `String` arguments with callbacks that return the string in a list. - When writing the label in a failure, write all lines, and use a postfix " that:". - Update some `Map` expectations which had manually joined with literal slash-n to keep the label or clause to a single line to take advantage of the multiline allowance. Split tests for the changed implementations and add tests for the descriptions with multiline examples. Some of these could have used multiline clauses before.
1 parent d2858ba commit 8ab184b

File tree

7 files changed

+115
-63
lines changed

7 files changed

+115
-63
lines changed

pkgs/checks/lib/src/checks.dart

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ extension Skip<T> on Subject<T> {
6363
Subject<T> check<T>(T value, {String? because}) => Subject._(_TestContext._root(
6464
value: _Present(value),
6565
// TODO - switch between "a" and "an"
66-
label: 'a $T',
66+
label: () => ['a $T'],
6767
fail: (f) {
6868
final which = f.rejection.which;
6969
throw TestFailure([
@@ -261,7 +261,8 @@ abstract class Context<T> {
261261
/// context. The [label] will be used as if it were a single line "clause"
262262
/// passed to [expect]. If the label is empty, the clause will be omitted. The
263263
/// label should only be left empty if the value extraction cannot fail.
264-
Subject<R> nest<R>(String label, Extracted<R> Function(T) extract,
264+
Subject<R> nest<R>(
265+
Iterable<String> Function() label, Extracted<R> Function(T) extract,
265266
{bool atSameLevel = false});
266267

267268
/// Extract an asynchronous property from the value for further checking.
@@ -277,8 +278,8 @@ abstract class Context<T> {
277278
/// Some context may disallow asynchronous expectations, for instance in
278279
/// [softCheck] which must synchronously check the value. In those contexts
279280
/// this method will throw.
280-
Future<Subject<R>> nestAsync<R>(
281-
String label, FutureOr<Extracted<R>> Function(T) extract);
281+
Future<Subject<R>> nestAsync<R>(Iterable<String> Function() label,
282+
FutureOr<Extracted<R>> Function(T) extract);
282283
}
283284

284285
/// A property extracted from a value being checked, or a rejection.
@@ -363,7 +364,7 @@ class _TestContext<T> implements Context<T>, _ClauseDescription {
363364
final List<_TestContext> _aliases;
364365

365366
// The "a value" in "a value that:".
366-
final String _label;
367+
final Iterable<String> Function() _label;
367368

368369
final void Function(CheckFailure) _fail;
369370

@@ -375,9 +376,9 @@ class _TestContext<T> implements Context<T>, _ClauseDescription {
375376
required void Function(CheckFailure) fail,
376377
required bool allowAsync,
377378
required bool allowUnawaited,
378-
String? label,
379+
Iterable<String> Function()? label,
379380
}) : _value = value,
380-
_label = label ?? '',
381+
_label = label ?? (() => ['']),
381382
_fail = fail,
382383
_allowAsync = allowAsync,
383384
_allowUnawaited = allowUnawaited,
@@ -394,7 +395,7 @@ class _TestContext<T> implements Context<T>, _ClauseDescription {
394395
_allowUnawaited = original._allowUnawaited,
395396
// Never read from an aliased context because they are never present in
396397
// `_clauses`.
397-
_label = '';
398+
_label = (() => ['']);
398399

399400
_TestContext._child(this._value, this._label, _TestContext<dynamic> parent)
400401
: _parent = parent,
@@ -444,20 +445,21 @@ class _TestContext<T> implements Context<T>, _ClauseDescription {
444445
}
445446

446447
@override
447-
Subject<R> nest<R>(String label, Extracted<R> Function(T) extract,
448+
Subject<R> nest<R>(
449+
Iterable<String> Function() label, Extracted<R> Function(T) extract,
448450
{bool atSameLevel = false}) {
449451
final result = _value.map((actual) => extract(actual)._fillActual(actual));
450452
final rejection = result.rejection;
451453
if (rejection != null) {
452-
_clauses.add(_StringClause(() => [label]));
454+
_clauses.add(_StringClause(label));
453455
_fail(_failure(rejection));
454456
}
455457
final value = result.value ?? _Absent<R>();
456458
final _TestContext<R> context;
457459
if (atSameLevel) {
458460
context = _TestContext._alias(this, value);
459461
_aliases.add(context);
460-
if (label.isNotEmpty) _clauses.add(_StringClause(() => [label]));
462+
_clauses.add(_StringClause(label));
461463
} else {
462464
context = _TestContext._child(value, label, this);
463465
_clauses.add(context);
@@ -466,8 +468,8 @@ class _TestContext<T> implements Context<T>, _ClauseDescription {
466468
}
467469

468470
@override
469-
Future<Subject<R>> nestAsync<R>(
470-
String label, FutureOr<Extracted<R>> Function(T) extract) async {
471+
Future<Subject<R>> nestAsync<R>(Iterable<String> Function() label,
472+
FutureOr<Extracted<R>> Function(T) extract) async {
471473
if (!_allowAsync) {
472474
throw StateError(
473475
'Async expectations cannot be used on a synchronous subject');
@@ -478,7 +480,7 @@ class _TestContext<T> implements Context<T>, _ClauseDescription {
478480
outstandingWork.complete();
479481
final rejection = result.rejection;
480482
if (rejection != null) {
481-
_clauses.add(_StringClause(() => [label]));
483+
_clauses.add(_StringClause(label));
482484
_fail(_failure(rejection));
483485
}
484486
final value = result.value ?? _Absent<R>();
@@ -507,9 +509,9 @@ class _TestContext<T> implements Context<T>, _ClauseDescription {
507509
var successfulOverlap = 0;
508510
final expected = <String>[];
509511
if (_clauses.isEmpty) {
510-
expected.add(_label);
512+
expected.addAll(_label());
511513
} else {
512-
expected.add('$_label that:');
514+
expected.addAll(postfixLast(' that:', _label()));
513515
for (var clause in _clauses) {
514516
final details = clause.detail(failingContext);
515517
expected.addAll(indent(details.expected));
@@ -550,14 +552,15 @@ class _SkippedContext<T> implements Context<T> {
550552
}
551553

552554
@override
553-
Subject<R> nest<R>(String label, Extracted<R> Function(T p1) extract,
555+
Subject<R> nest<R>(
556+
Iterable<String> Function() label, Extracted<R> Function(T p1) extract,
554557
{bool atSameLevel = false}) {
555558
return Subject._(_SkippedContext());
556559
}
557560

558561
@override
559-
Future<Subject<R>> nestAsync<R>(
560-
String label, FutureOr<Extracted<R>> Function(T p1) extract) async {
562+
Future<Subject<R>> nestAsync<R>(Iterable<String> Function() label,
563+
FutureOr<Extracted<R>> Function(T p1) extract) async {
561564
return Subject._(_SkippedContext());
562565
}
563566
}
@@ -766,7 +769,8 @@ class _ReplayContext<T> implements Context<T>, Condition<T> {
766769
}
767770

768771
@override
769-
Subject<R> nest<R>(String label, Extracted<R> Function(T p1) extract,
772+
Subject<R> nest<R>(
773+
Iterable<String> Function() label, Extracted<R> Function(T p1) extract,
770774
{bool atSameLevel = false}) {
771775
final nestedContext = _ReplayContext<R>();
772776
_interactions.add((c) {
@@ -777,8 +781,8 @@ class _ReplayContext<T> implements Context<T>, Condition<T> {
777781
}
778782

779783
@override
780-
Future<Subject<R>> nestAsync<R>(
781-
String label, FutureOr<Extracted<R>> Function(T) extract) async {
784+
Future<Subject<R>> nestAsync<R>(Iterable<String> Function() label,
785+
FutureOr<Extracted<R>> Function(T) extract) async {
782786
final nestedContext = _ReplayContext<R>();
783787
_interactions.add((c) async {
784788
var result = await c.nestAsync(label, extract);

pkgs/checks/lib/src/extensions/async.dart

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ extension FutureChecks<T> on Subject<Future<T>> {
1616
///
1717
/// Fails if the future completes as an error.
1818
Future<Subject<T>> completes() =>
19-
context.nestAsync<T>('completes to a value', (actual) async {
19+
context.nestAsync<T>(() => ['completes to a value'], (actual) async {
2020
try {
2121
return Extracted.value(await actual);
2222
} catch (e, st) {
@@ -61,7 +61,7 @@ extension FutureChecks<T> on Subject<Future<T>> {
6161
///
6262
/// Fails if the future completes to a value.
6363
Future<Subject<E>> throws<E extends Object>() => context.nestAsync<E>(
64-
'completes to an error${E == Object ? '' : ' of type $E'}',
64+
() => ['completes to an error${E == Object ? '' : ' of type $E'}'],
6565
(actual) async {
6666
try {
6767
return Extracted.rejection(
@@ -110,7 +110,7 @@ extension StreamChecks<T> on Subject<StreamQueue<T>> {
110110
/// Fails if the stream emits an error instead of a value, or closes without
111111
/// emitting a value.
112112
Future<Subject<T>> emits() =>
113-
context.nestAsync<T>('emits a value', (actual) async {
113+
context.nestAsync<T>(() => ['emits a value'], (actual) async {
114114
if (!await actual.hasNext) {
115115
return Extracted.rejection(
116116
actual: ['a stream'],
@@ -140,8 +140,8 @@ extension StreamChecks<T> on Subject<StreamQueue<T>> {
140140
/// If this expectation fails, the source queue will be left in it's original
141141
/// state.
142142
/// If this expectation succeeds, consumes the error event.
143-
Future<Subject<E>> emitsError<E extends Object>() =>
144-
context.nestAsync('emits an error${E == Object ? '' : ' of type $E'}',
143+
Future<Subject<E>> emitsError<E extends Object>() => context.nestAsync(
144+
() => ['emits an error${E == Object ? '' : ' of type $E'}'],
145145
(actual) async {
146146
if (!await actual.hasNext) {
147147
return Extracted.rejection(
@@ -462,6 +462,6 @@ extension StreamQueueWrap<T> on Subject<Stream<T>> {
462462
/// so that they can support conditional expectations and check multiple
463463
/// possibilities from the same point in the stream.
464464
Subject<StreamQueue<T>> get withQueue =>
465-
context.nest('', (actual) => Extracted.value(StreamQueue(actual)),
465+
context.nest(() => [], (actual) => Extracted.value(StreamQueue(actual)),
466466
atSameLevel: true);
467467
}

pkgs/checks/lib/src/extensions/core.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ extension CoreChecks<T> on Subject<T> {
1010
/// Sets up a clause that the value "has [name] that:" followed by any
1111
/// expectations applied to the returned [Subject].
1212
Subject<R> has<R>(R Function(T) extract, String name) {
13-
return context.nest('has $name', (T value) {
13+
return context.nest(() => ['has $name'], (T value) {
1414
try {
1515
return Extracted.value(extract(value));
1616
} catch (_) {
@@ -70,7 +70,7 @@ extension CoreChecks<T> on Subject<T> {
7070
///
7171
/// If the value is a [T], returns a [Subject] for further expectations.
7272
Subject<R> isA<R>() {
73-
return context.nest<R>('is a $R', (actual) {
73+
return context.nest<R>(() => ['is a $R'], (actual) {
7474
if (actual is! R) {
7575
return Extracted.rejection(which: ['Is a ${actual.runtimeType}']);
7676
}
@@ -118,7 +118,7 @@ extension BoolChecks on Subject<bool> {
118118

119119
extension NullabilityChecks<T> on Subject<T?> {
120120
Subject<T> isNotNull() {
121-
return context.nest<T>('is not null', (actual) {
121+
return context.nest<T>(() => ['is not null'], (actual) {
122122
if (actual == null) return Extracted.rejection();
123123
return Extracted.value(actual);
124124
}, atSameLevel: true);

pkgs/checks/lib/src/extensions/function.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ extension ThrowsCheck<T> on Subject<T Function()> {
1717
/// fail. Instead invoke the function and check the expectation on the
1818
/// returned [Future].
1919
Subject<E> throws<E>() {
20-
return context.nest<E>('throws an error of type $E', (actual) {
20+
return context.nest<E>(() => ['throws an error of type $E'], (actual) {
2121
try {
2222
final result = actual();
2323
return Extracted.rejection(
@@ -40,7 +40,7 @@ extension ThrowsCheck<T> on Subject<T Function()> {
4040
///
4141
/// If the function throws synchronously, this expectation will fail.
4242
Subject<T> returnsNormally() {
43-
return context.nest<T>('returns a value', (actual) {
43+
return context.nest<T>(() => ['returns a value'], (actual) {
4444
try {
4545
return Extracted.value(actual());
4646
} catch (e, st) {

pkgs/checks/lib/src/extensions/map.dart

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ extension MapChecks<K, V> on Subject<Map<K, V>> {
1414
Subject<Iterable<V>> get values => has((m) => m.values, 'values');
1515
Subject<int> get length => has((m) => m.length, 'length');
1616
Subject<V> operator [](K key) {
17-
final keyString = literal(key).join(r'\n');
18-
return context.nest('contains a value for $keyString', (actual) {
17+
return context.nest(
18+
() => prefixFirst('contains a value for ', literal(key)), (actual) {
1919
if (!actual.containsKey(key)) {
2020
return Extracted.rejection(
21-
which: ['does not contain the key $keyString']);
21+
which: prefixFirst('does not contain the key ', literal(key)));
2222
}
2323
return Extracted.value(actual[key] as V);
2424
});
@@ -40,10 +40,10 @@ extension MapChecks<K, V> on Subject<Map<K, V>> {
4040

4141
/// Expects that the map contains [key] according to [Map.containsKey].
4242
void containsKey(K key) {
43-
final keyString = literal(key).join(r'\n');
44-
context.expect(() => ['contains key $keyString'], (actual) {
43+
context.expect(() => prefixFirst('contains key ', literal(key)), (actual) {
4544
if (actual.containsKey(key)) return null;
46-
return Rejection(which: ['does not contain key $keyString']);
45+
return Rejection(
46+
which: prefixFirst('does not contain key ', literal(key)));
4747
});
4848
}
4949

@@ -68,10 +68,11 @@ extension MapChecks<K, V> on Subject<Map<K, V>> {
6868

6969
/// Expects that the map contains [value] according to [Map.containsValue].
7070
void containsValue(V value) {
71-
final valueString = literal(value).join(r'\n');
72-
context.expect(() => ['contains value $valueString'], (actual) {
71+
context.expect(() => prefixFirst('contains value ', literal(value)),
72+
(actual) {
7373
if (actual.containsValue(value)) return null;
74-
return Rejection(which: ['does not contain value $valueString']);
74+
return Rejection(
75+
which: prefixFirst('does not contain value ', literal(value)));
7576
});
7677
}
7778

pkgs/checks/test/extensions/map_test.dart

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,31 @@ void main() {
3030
check(_testMap).values.contains(1);
3131
});
3232

33-
test('operator []', () async {
34-
check(_testMap)['a'].equals(1);
35-
check(_testMap)
36-
.isRejectedBy(it()..['z'], which: ['does not contain the key \'z\'']);
33+
group('operator []', () {
34+
test('succeeds for a key that exists', () {
35+
check(_testMap)['a'].equals(1);
36+
});
37+
test('fails for a missing key', () {
38+
check(_testMap)
39+
.isRejectedBy(it()..['z'], which: ['does not contain the key \'z\'']);
40+
});
41+
test('can be described', () {
42+
check(it<Map<String, Object>>()..['some\nlong\nkey'])
43+
.description
44+
.deepEquals([
45+
" contains a value for 'some",
46+
' long',
47+
" key'",
48+
]);
49+
check(it<Map<String, Object>>()..['some\nlong\nkey'].equals(1))
50+
.description
51+
.deepEquals([
52+
" contains a value for 'some",
53+
' long',
54+
" key' that:",
55+
' equals <1>',
56+
]);
57+
});
3758
});
3859
test('isEmpty', () {
3960
check(<String, int>{}).isEmpty();
@@ -43,13 +64,25 @@ void main() {
4364
check(_testMap).isNotEmpty();
4465
check({}).isRejectedBy(it()..isNotEmpty(), which: ['is not empty']);
4566
});
46-
test('containsKey', () {
47-
check(_testMap).containsKey('a');
48-
49-
check(_testMap).isRejectedBy(
50-
it()..containsKey('c'),
51-
which: ["does not contain key 'c'"],
52-
);
67+
group('containsKey', () {
68+
test('succeeds for a key that exists', () {
69+
check(_testMap).containsKey('a');
70+
});
71+
test('fails for a missing key', () {
72+
check(_testMap).isRejectedBy(
73+
it()..containsKey('c'),
74+
which: ["does not contain key 'c'"],
75+
);
76+
});
77+
test('can be described', () {
78+
check(it<Map<String, Object>>()..containsKey('some\nlong\nkey'))
79+
.description
80+
.deepEquals([
81+
" contains key 'some",
82+
' long',
83+
" key'",
84+
]);
85+
});
5386
});
5487
test('containsKeyThat', () {
5588
check(_testMap).containsKeyThat(it()..equals('a'));
@@ -58,12 +91,25 @@ void main() {
5891
which: ['Contains no matching key'],
5992
);
6093
});
61-
test('containsValue', () {
62-
check(_testMap).containsValue(1);
63-
check(_testMap).isRejectedBy(
64-
it()..containsValue(3),
65-
which: ['does not contain value <3>'],
66-
);
94+
group('containsValue', () {
95+
test('succeeds for happy case', () {
96+
check(_testMap).containsValue(1);
97+
});
98+
test('fails for missing value', () {
99+
check(_testMap).isRejectedBy(
100+
it()..containsValue(3),
101+
which: ['does not contain value <3>'],
102+
);
103+
});
104+
test('can be described', () {
105+
check(it<Map<String, String>>()..containsValue('some\nlong\nkey'))
106+
.description
107+
.deepEquals([
108+
" contains value 'some",
109+
' long',
110+
" key'",
111+
]);
112+
});
67113
});
68114
test('containsValueThat', () {
69115
check(_testMap).containsValueThat(it()..equals(1));

0 commit comments

Comments
 (0)