Skip to content

Commit

Permalink
Merge pull request #26 from srawlins/typed-api
Browse files Browse the repository at this point in the history
Add strong mode-compliant 'typed' API
  • Loading branch information
TedSander authored Aug 3, 2016
2 parents 2513f9e + beb8b26 commit 625f0f4
Show file tree
Hide file tree
Showing 5 changed files with 355 additions and 4 deletions.
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 1.0.0

* Add a new `typed` API that is compatible with Dart Dev Compiler; documented in
README.md.

## 0.11.1

* Move the reflection-based `spy` code into a private source file. Now
`package:mockito/mockito.dart` includes this reflection-based API, and a new
`package:mockito/mockito_no_mirrors.dart` doesn't require mirrors.

## 0.11.0

* Equality matcher used by default to simplify matching collections as arguments. Should be non-breaking change in most cases, otherwise consider using `argThat(identical(arg))`.
Expand Down
86 changes: 84 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,11 @@ verifyNoMoreInteractions(cat);
```dart
//simple capture
cat.eatFood("Fish");
expect(verify(cat.eatFood(capture)).captured.single, "Fish");
expect(verify(cat.eatFood(captureAny)).captured.single, "Fish");
//capture multiple calls
cat.eatFood("Milk");
cat.eatFood("Fish");
expect(verify(cat.eatFood(capture)).captured, ["Milk", "Fish"]);
expect(verify(cat.eatFood(captureAny)).captured, ["Milk", "Fish"]);
//conditional capture
cat.eatFood("Milk");
cat.eatFood("Fish");
Expand All @@ -147,6 +147,88 @@ expect(cat.sound(), "Purr");
//using real object
expect(cat.lives, 9);
```

## Strong mode compliance

Unfortunately, the use of the arg matchers in mock method calls (like `cat.eatFood(any)`)
violates the [Strong mode] type system. Specifically, if the method signature of a mocked
method has a parameter with a parameterized type (like `List<int>`), then passing `any` or
`argThat` will result in a Strong mode warning:

> [warning] Unsound implicit cast from dynamic to List&lt;int>
In order to write Strong mode-compliant tests with Mockito, you might need to use `typed`,
annotating it with a type parameter comment. Let's use a slightly different `Cat` class to
show some examples:

```dart
class Cat {
bool eatFood(List<String> foods, [List<String> mixins]) => true;
int walk(List<String> places, {Map<String, String> gaits}) => 0;
}
class MockCat extends Mock implements Cat {}
var cat = new MockCat();
```

OK, what if we try to stub using `any`:

```dart
when(cat.eatFood(any)).thenReturn(true);
```

Let's analyze this code:

```
$ dartanalyzer --strong test/cat_test.dart
Analyzing [lib/cat_test.dart]...
[warning] Unsound implicit cast from dynamic to List<String> (test/cat_test.dart, line 12, col 20)
1 warning found.
```

This code is not Strong mode-compliant. Let's change it to use `typed`:

```dart
when(cat.eatFood(typed(any)))
```

```
$ dartanalyzer --strong test/cat_test.dart
Analyzing [lib/cat_test.dart]...
No issues found
```

Great! A little ugly, but it works. Here are some more examples:

```dart
when(cat.eatFood(typed(any), typed(any))).thenReturn(true);
when(cat.eatFood(typed(argThat(contains("fish"))))).thenReturn(true);
```

Named args require one more component: `typed` needs to know what named argument it is
being passed into:

```dart
when(cat.walk(typed(any), gaits: typed(any, named: 'gaits')))
.thenReturn(true);
```

Note the `named` argument. Mockito should fail gracefully if you forget to name a `typed`
call passed in as a named argument, or name the argument incorrectly.

One more note about the `typed` API: you cannot mix `typed` arguments with `null`
arguments:

```dart
when(cat.eatFood(null, typed(any))).thenReturn(true); // Throws!
when(cat.eatFood(
argThat(equals(null)),
typed(any))).thenReturn(true); // Works.
```

[Strong mode]: https://github.com/dart-lang/dev_compiler/blob/master/STRONG_MODE.md

## How it works
The basics of the `Mock` class are nothing special: It uses `noSuchMethod` to catch
all method invocations, and returns the value that you have configured beforehand with
Expand Down
156 changes: 156 additions & 0 deletions lib/src/mock.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ _WhenCall _whenCall = null;
final List<_VerifyCall> _verifyCalls = <_VerifyCall>[];
final _TimeStampProvider _timer = new _TimeStampProvider();
final List _capturedArgs = [];
final List<_ArgMatcher> _typedArgs = <_ArgMatcher>[];
final Map<String, _ArgMatcher> _typedNamedArgs = <String, _ArgMatcher>{};

// Hidden from the public API, used by spy.dart.
void setDefaultResponse(Mock mock, dynamic defaultResponse) {
Expand All @@ -31,6 +33,9 @@ class Mock {
}

dynamic noSuchMethod(Invocation invocation) {
if (_typedArgs.isNotEmpty || _typedNamedArgs.isNotEmpty) {
invocation = new _InvocationForTypedArguments(invocation);
}
if (_whenInProgress) {
_whenCall = new _WhenCall(this, invocation);
return null;
Expand All @@ -55,6 +60,137 @@ class Mock {
String toString() => _givenName != null ? _givenName : runtimeType.toString();
}

/// An Invocation implementation that takes arguments from [_typedArgs] and
/// [_typedNamedArgs].
class _InvocationForTypedArguments extends Invocation {
final Symbol memberName;
final Map<Symbol, dynamic> namedArguments;
final List<dynamic> positionalArguments;
final bool isGetter;
final bool isMethod;
final bool isSetter;

factory _InvocationForTypedArguments(Invocation invocation) {
if (_typedArgs.isEmpty && _typedNamedArgs.isEmpty) {
throw new StateError(
"_InvocationForTypedArguments called when no typed calls have been saved.");
}

// Handle named arguments first, so that we can provide useful errors for
// the various bad states. If all is well with the named arguments, then we
// can process the positional arguments, and resort to more general errors
// if the state is still bad.
var namedArguments = _reconstituteNamedArgs(invocation);
var positionalArguments = _reconstitutePositionalArgs(invocation);

_typedArgs.clear();
_typedNamedArgs.clear();

return new _InvocationForTypedArguments._(
invocation.memberName,
positionalArguments,
namedArguments,
invocation.isGetter,
invocation.isMethod,
invocation.isSetter);
}

// Reconstitutes the named arguments in an invocation from [_typedNamedArgs].
//
// The namedArguments in [invocation] which are null should be represented
// by a stored value in [_typedNamedArgs]. The null presumably came from
// [typed].
static Map<Symbol,dynamic> _reconstituteNamedArgs(Invocation invocation) {
var namedArguments = <Symbol, dynamic>{};
var _typedNamedArgSymbols = _typedNamedArgs.keys.map((name) => new Symbol(name));

// Iterate through [invocation]'s named args, validate them, and add them
// to the return map.
invocation.namedArguments.forEach((name, arg) {
if (arg == null) {
if (!_typedNamedArgSymbols.contains(name)) {
// Incorrect usage of [typed], something like:
// `when(obj.fn(a: typed(any)))`.
throw new ArgumentError(
'A typed argument was passed in as a named argument named "$name", '
'but did not pass a value for `named`. Each typed argument that is '
'passed as a named argument needs to specify the `named` argument. '
'For example: `when(obj.fn(x: typed(any, named: "x")))`.');
}
} else {
// Add each real named argument that was _not_ passed with [typed].
namedArguments[name] = arg;
}
});

// Iterate through the stored named args (stored with [typed]), validate
// them, and add them to the return map.
_typedNamedArgs.forEach((name, arg) {
Symbol nameSymbol = new Symbol(name);
if (!invocation.namedArguments.containsKey(nameSymbol)) {
throw new ArgumentError(
'A typed argument was declared as named $name, but was not passed '
'as an argument named $name.\n\n'
'BAD: when(obj.fn(typed(any, named: "a")))\n'
'GOOD: when(obj.fn(a: typed(any, named: "a")))');
}
if (invocation.namedArguments[nameSymbol] != null) {
throw new ArgumentError(
'A typed argument was declared as named $name, but a different '
'value (${invocation.namedArguments[nameSymbol]}) was passed as '
'$name.\n\n'
'BAD: when(obj.fn(b: typed(any, name: "a")))\n'
'GOOD: when(obj.fn(b: typed(any, name: "b")))');
}
namedArguments[nameSymbol] = arg;
});

return namedArguments;
}

static List<dynamic> _reconstitutePositionalArgs(Invocation invocation) {
var positionalArguments = <dynamic>[];
var nullPositionalArguments =
invocation.positionalArguments.where((arg) => arg == null);
if (_typedArgs.length != nullPositionalArguments.length) {
throw new ArgumentError(
'null arguments are not allowed alongside typed(); use '
'"typed(eq(null))"');
}
int typedIndex = 0;
int positionalIndex = 0;
while (typedIndex < _typedArgs.length &&
positionalIndex < invocation.positionalArguments.length) {
var arg = _typedArgs[typedIndex];
if (invocation.positionalArguments[positionalIndex] == null) {
// [typed] was used; add the [_ArgMatcher] given to [typed].
positionalArguments.add(arg);
typedIndex++;
positionalIndex++;
} else {
// [typed] was not used; add the [_ArgMatcher] from [invocation].
positionalArguments.add(invocation.positionalArguments[positionalIndex]);
positionalIndex++;
}
}
while (positionalIndex < invocation.positionalArguments.length) {
// Some trailing non-[typed] arguments.
positionalArguments.add(invocation.positionalArguments[positionalIndex]);
positionalIndex++;
}

return positionalArguments;
}

_InvocationForTypedArguments._(
this.memberName,
this.positionalArguments,
this.namedArguments,
this.isGetter,
this.isMethod,
this.isSetter);
}

named(var mock, {String name, int hashCode}) => mock
.._givenName = name
.._givenHashCode = hashCode;
Expand Down Expand Up @@ -300,6 +436,15 @@ get captureAny => new _ArgMatcher(anything, true);
captureThat(Matcher matcher) => new _ArgMatcher(matcher, true);
argThat(Matcher matcher) => new _ArgMatcher(matcher, false);

/*=T*/ typed/*<T>*/(_ArgMatcher matcher, {String named}) {
if (named == null) {
_typedArgs.add(matcher);
} else {
_typedNamedArgs[named] = matcher;
}
return null;
}

class VerificationResult {
List captured = [];
int callCount;
Expand Down Expand Up @@ -413,3 +558,14 @@ void logInvocations(List<Mock> mocks) {
print(inv.toString());
});
}

/// Should only be used during Mockito testing.
void resetMockitoState() {
_whenInProgress = false;
_verificationInProgress = false;
_whenCall = null;
_verifyCalls.clear();
_capturedArgs.clear();
_typedArgs.clear();
_typedNamedArgs.clear();
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: mockito
version: 0.11.1
version: 1.0.0
author: Dmitriy Fibulwinter <[email protected]>
description: A mock framework inspired by Mockito.
homepage: https://github.com/fibulwinter/dart-mockito
Expand Down
Loading

0 comments on commit 625f0f4

Please sign in to comment.