Skip to content
Open
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
134 changes: 134 additions & 0 deletions packages/mocktail/lib/src/_invocation_matcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,138 @@ class InvocationMatcher {
bool _isMatchingTypeArg(Type roleTypeArg, dynamic actTypeArg) {
return roleTypeArg == actTypeArg;
}

/// Returns an [_ArgumentMismatch] for each argument of [invocation] that
/// does not match [roleInvocation].
///
/// Returns `null` when [invocation] does not target the same member with
/// the same shape (positional arity, named argument keys and type
/// arguments), in which case a per-argument comparison is meaningless.
List<_ArgumentMismatch>? _argumentMismatches(Invocation invocation) {
if (!_isMethodMatches(invocation)) return null;
if (invocation.positionalArguments.length !=
roleInvocation.positionalArguments.length) {
return null;
}
if (invocation.typeArguments.length !=
roleInvocation.typeArguments.length) {
return null;
}
final roleKeys = roleInvocation.namedArguments.keys.toSet();
final actKeys = invocation.namedArguments.keys.toSet();
if (roleKeys.difference(actKeys).isNotEmpty ||
actKeys.difference(roleKeys).isNotEmpty) {
return null;
}

var typeArgIndex = 0;
for (final roleTypeArg in roleInvocation.typeArguments) {
final dynamic actTypeArg = invocation.typeArguments[typeArgIndex];
if (!_isMatchingTypeArg(roleTypeArg, actTypeArg)) return null;
typeArgIndex++;
}

final mismatches = <_ArgumentMismatch>[];
var positionalArgIndex = 0;
for (final roleArg in roleInvocation.positionalArguments) {
final dynamic actArg = invocation.positionalArguments[positionalArgIndex];
if (!_isMatchingArg(roleArg, actArg)) {
mismatches.add(
_ArgumentMismatch(
'positional argument #$positionalArgIndex',
roleArg,
actArg,
),
);
}
positionalArgIndex++;
}
for (final roleKey in roleInvocation.namedArguments.keys) {
final dynamic roleArg = roleInvocation.namedArguments[roleKey];
final dynamic actArg = invocation.namedArguments[roleKey];
if (!_isMatchingArg(roleArg, actArg)) {
mismatches.add(
_ArgumentMismatch(
"named argument '${_symbolToString(roleKey)}'",
roleArg,
actArg,
),
);
}
}
return mismatches;
}
}

/// The length beyond which a mismatched [String] argument is considered
/// large enough that a rich diff (via [Matcher.describeMismatch]) is more
/// readable than the plain listing of the two calls.
const _stringDiffThreshold = 40;

/// The number of elements beyond which a mismatched collection argument is
/// considered large enough for a rich diff.
const _collectionDiffThreshold = 5;

/// A single argument of a real invocation that did not match the
/// corresponding argument of the invocation being verified.
class _ArgumentMismatch {
factory _ArgumentMismatch(String location, dynamic roleArg, dynamic actArg) {
if (roleArg is ArgMatcher) {
return _ArgumentMismatch._(location, roleArg.matcher, null, actArg);
}
return _ArgumentMismatch._(location, equals(roleArg), roleArg, actArg);
}

const _ArgumentMismatch._(
this.location,
this.matcher,
this.expectedValue,
this.actualArg,
);

/// A human readable location of the argument within the invocation,
/// e.g. `positional argument #0` or `named argument 'style'`.
final String location;

/// The [Matcher] the argument was compared against.
final Matcher matcher;

/// The expected value, or `null` when the expectation was an [ArgMatcher]
/// rather than a concrete value.
final dynamic expectedValue;

/// The actual value the mock was invoked with.
final dynamic actualArg;

/// Whether the mismatched values are large enough that a rich diff is
/// meaningfully more readable than the existing short form.
bool get isDiffWorthy => _isLarge(expectedValue) || _isLarge(actualArg);

static bool _isLarge(dynamic value) {
if (value is String) {
return value.contains('\n') || value.length > _stringDiffThreshold;
}
if (value is Iterable) return value.length > _collectionDiffThreshold;
if (value is Map) return value.length > _collectionDiffThreshold;
return false;
}

/// Describes the mismatch using [matcher], which produces readable diffs
/// for long strings (pinpointing the offset at which they differ) and for
/// collections (pinpointing the mismatched index or key).
String describe() {
final mismatch = StringDescription();
// Matchers expect [Matcher.describeMismatch] to receive the same match
// state that [Matcher.matches] populated for the mismatched value.
final matchState = <dynamic, dynamic>{};
matcher
..matches(actualArg, matchState)
..describeMismatch(actualArg, mismatch, matchState, false);
final description = mismatch.toString();
if (description.trim().isNotEmpty) return description;
final expected = StringDescription();
matcher.describe(expected);
final actual = StringDescription()..addDescriptionOf(actualArg);
return 'expected $expected but was $actual';
}
}
36 changes: 36 additions & 0 deletions packages/mocktail/lib/src/mocktail.dart
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,10 @@ class _VerifyCall {
} else {
final otherCalls = mock._realCallsToString();
message = 'No matching calls. All calls: $otherCalls';
final closestMismatch = _describeClosestMismatch();
if (closestMismatch != null) {
message = '$message\n$closestMismatch';
}
}
fail('$message\n'
'(If you called `verify(...).called(0);`, please instead use '
Expand All @@ -738,6 +742,38 @@ class _VerifyCall {
}
}

/// Describes why the closest real call did not match [verifyInvocation],
/// or `null` when no such description would help.
///
/// The closest call is the unverified call that targets the same member
/// with the same shape and mismatches on the fewest arguments. Returns
/// `null` when there is no such call, or when every mismatched value is
/// small enough that the plain call listing already makes the difference
/// obvious.
String? _describeClosestMismatch() {
final expectedMatcher = InvocationMatcher(verifyInvocation);
List<_ArgumentMismatch>? closest;
for (final realCall in mock._realCalls) {
if (realCall.verified) continue;
final mismatches =
expectedMatcher._argumentMismatches(realCall.invocation);
if (mismatches == null || mismatches.isEmpty) continue;
if (closest == null || mismatches.length < closest.length) {
closest = mismatches;
}
}
if (closest == null || !closest.any((mismatch) => mismatch.isDiffWorthy)) {
return null;
}
final member = _symbolToString(verifyInvocation.memberName);
final buffer = StringBuffer('Closest matching call: $member');
for (final mismatch in closest) {
final description = mismatch.describe().split('\n').join('\n ');
buffer.write('\n ${mismatch.location}: $description');
}
return buffer.toString();
}

@override
String toString() =>
'VerifyCall<mock: $mock, memberName: ${verifyInvocation.memberName}>';
Expand Down
182 changes: 182 additions & 0 deletions packages/mocktail/test/string_diff_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class _RealClass {
String? methodWithStringArgs(String? x) => 'Real';
String? methodWithTwoStringArgs(String? x, String? y) => 'Real';
String? methodWithNamedStringArgs({String? x}) => 'Real';
String? methodWithListArgs(List<int>? x) => 'Real';
String? methodWithMapArgs(Map<String, int>? x) => 'Real';
String? methodWithNormalArgs(int? x) => 'Real';
String? otherMethodWithStringArgs(String? x) => 'Real';
}

class _MockedClass extends Mock implements _RealClass {}

String failureMessageOf(void Function() expectedToFail) {
try {
expectedToFail();
} on TestFailure catch (e) {
return e.message ?? '';
}
fail('It was expected to fail!');
}

void main() {
late _MockedClass mock;

const longString =
'Your next step is to upload the app bundle to the Play Store: '
'build/app/outputs/bundle/release/app-release.aab';

setUp(() {
mock = _MockedClass();
});

tearDown(resetMocktailState);

group('verify argument diffs', () {
test('describes the diff when a long string argument mismatches', () {
mock.methodWithStringArgs(longString.replaceFirst('bundle', 'bundel'));
final message = failureMessageOf(
() => verify(() => mock.methodWithStringArgs(longString)),
);
expect(message, contains('No matching calls.'));
expect(
message,
contains('Closest matching call: methodWithStringArgs'),
);
expect(message, contains('positional argument #0'));
expect(message, contains('Differ at offset'));
});

test('describes the diff when a multiline string argument mismatches', () {
mock.methodWithStringArgs('first\nsecond');
final message = failureMessageOf(
() => verify(() => mock.methodWithStringArgs('first\nsecund')),
);
expect(
message,
contains('Closest matching call: methodWithStringArgs'),
);
expect(message, contains('Differ at offset'));
});

test('describes the diff for a mismatched named string argument', () {
mock.methodWithNamedStringArgs(
x: longString.replaceFirst('bundle', 'bundel'),
);
final message = failureMessageOf(
() => verify(() => mock.methodWithNamedStringArgs(x: longString)),
);
expect(
message,
contains('Closest matching call: methodWithNamedStringArgs'),
);
expect(message, contains("named argument 'x'"));
expect(message, contains('Differ at offset'));
});

test('describes the mismatched location for a large list argument', () {
mock.methodWithListArgs([1, 2, 3, 4, 5, 6, 7]);
final message = failureMessageOf(
() => verify(() => mock.methodWithListArgs([1, 2, 3, 9, 5, 6, 7])),
);
expect(
message,
contains('Closest matching call: methodWithListArgs'),
);
expect(message, contains('at location [3]'));
});

test('describes the mismatched location for a large map argument', () {
mock.methodWithMapArgs(
{'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 6},
);
final message = failureMessageOf(
() => verify(
() => mock.methodWithMapArgs(
{'a': 1, 'b': 2, 'c': 3, 'd': 4, 'e': 5, 'f': 9},
),
),
);
expect(
message,
contains('Closest matching call: methodWithMapArgs'),
);
expect(message, contains("at location ['f']"));
});

test('only describes the arguments that mismatch', () {
mock.methodWithTwoStringArgs('$longString a', 'same');
final message = failureMessageOf(
() => verify(
() => mock.methodWithTwoStringArgs('$longString b', 'same'),
),
);
expect(message, contains('positional argument #0'));
expect(message, isNot(contains('positional argument #1')));
});

test('describes the closest call when multiple calls mismatch', () {
mock
..methodWithTwoStringArgs('$longString a', 'different')
..methodWithTwoStringArgs('$longString a', 'same');
final message = failureMessageOf(
() => verify(
() => mock.methodWithTwoStringArgs('$longString b', 'same'),
),
);
expect(message, contains('positional argument #0'));
expect(message, isNot(contains('positional argument #1')));
});

test('keeps the short form for small values', () {
mock.methodWithNormalArgs(17);
final message = failureMessageOf(
() => verify(() => mock.methodWithNormalArgs(18)),
);
expect(message, contains('No matching calls.'));
expect(message, isNot(contains('Closest matching call')));
});

test('keeps the short form for short strings', () {
mock.methodWithStringArgs('foo');
final message = failureMessageOf(
() => verify(() => mock.methodWithStringArgs('bar')),
);
expect(message, isNot(contains('Closest matching call')));
});

test('keeps the short form for small collections', () {
mock.methodWithListArgs([42]);
final message = failureMessageOf(
() => verify(() => mock.methodWithListArgs([43])),
);
expect(message, isNot(contains('Closest matching call')));
});

test('does not describe calls to a different member', () {
mock.otherMethodWithStringArgs(longString);
final message = failureMessageOf(
() => verify(() => mock.methodWithStringArgs(longString)),
);
expect(message, contains('No matching calls.'));
expect(message, isNot(contains('Closest matching call')));
});

test('describes the diff for a matcher argument with a long actual', () {
mock.methodWithStringArgs(longString.replaceFirst('bundle', 'bundel'));
final message = failureMessageOf(
() => verify(
() => mock.methodWithStringArgs(any(that: equals(longString))),
),
);
expect(
message,
contains('Closest matching call: methodWithStringArgs'),
);
expect(message, contains('Differ at offset'));
});
});
}