Merge pull request #26 from srawlins/typed-api
Add strong mode-compliant 'typed' API
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c1f150..9534f53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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))`.
diff --git a/README.md b/README.md
index 36e9316..d5e362c 100644
--- a/README.md
+++ b/README.md
@@ -126,11 +126,11 @@
```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");
@@ -147,6 +147,88 @@
//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<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
diff --git a/lib/src/mock.dart b/lib/src/mock.dart
index 71b8d9c..c1bf386 100644
--- a/lib/src/mock.dart
+++ b/lib/src/mock.dart
@@ -10,6 +10,8 @@
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) {
@@ -31,6 +33,9 @@
}
dynamic noSuchMethod(Invocation invocation) {
+ if (_typedArgs.isNotEmpty || _typedNamedArgs.isNotEmpty) {
+ invocation = new _InvocationForTypedArguments(invocation);
+ }
if (_whenInProgress) {
_whenCall = new _WhenCall(this, invocation);
return null;
@@ -55,6 +60,137 @@
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;
@@ -300,6 +436,15 @@
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;
@@ -413,3 +558,14 @@
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();
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 4cdcff5..be8576c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: mockito
-version: 0.11.1
+version: 1.0.0
author: Dmitriy Fibulwinter <fibulwinter@gmail.com>
description: A mock framework inspired by Mockito.
homepage: https://github.com/fibulwinter/dart-mockito
diff --git a/test/mockito_test.dart b/test/mockito_test.dart
index 03a2ed9..fe20df2 100644
--- a/test/mockito_test.dart
+++ b/test/mockito_test.dart
@@ -9,6 +9,14 @@
String methodWithNamedArgs(int x, {int y}) => "Real";
String methodWithTwoNamedArgs(int x, {int y, int z}) => "Real";
String methodWithObjArgs(RealClass x) => "Real";
+ // "SpecialArgs" here means type-parameterized args. But that makes for a long
+ // method name.
+ String typeParameterizedFn(
+ List<int> w, List<int> x, [List<int> y, List<int> z]) => "Real";
+ // "SpecialNamedArgs" here means type-parameterized, named args. But that
+ // makes for a long method name.
+ String typeParameterizedNamedFn(List<int> w, List<int> x, {List<int> y, List<int> z}) =>
+ "Real";
String get getter => "Real";
void set setter(String arg) {
throw new StateError("I must be mocked");
@@ -55,6 +63,12 @@
mock = new MockedClass();
});
+ tearDown(() {
+ // In some of the tests that expect an Error to be thrown, Mockito's
+ // global state can become invalid. Reset it.
+ resetMockitoState();
+ });
+
group("spy", () {
setUp(() {
mock = spy(new MockedClass(), new RealClass());
@@ -204,6 +218,81 @@
when(mock.methodWithNormalArgs(argThat(equals(42)))).thenReturn("42");
expect(mock.methodWithNormalArgs(43), equals("43"));
});
+ test("should mock method with typed arg matchers", () {
+ when(mock.typeParameterizedFn(typed(any), typed(any)))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedFn([42], [43]), equals("A lot!"));
+ expect(mock.typeParameterizedFn([43], [44]), equals("A lot!"));
+ });
+ test("should mock method with an optional typed arg matcher", () {
+ when(mock.typeParameterizedFn(typed(any), typed(any), typed(any)))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedFn([42], [43], [44]), equals("A lot!"));
+ });
+ test("should mock method with an optional typed arg matcher and an optional real arg", () {
+ when(mock.typeParameterizedFn(typed(any), typed(any), [44], typed(any)))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedFn([42], [43], [44], [45]), equals("A lot!"));
+ });
+ test("should mock method with only some typed arg matchers", () {
+ when(mock.typeParameterizedFn(typed(any), [43], typed(any)))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedFn([42], [43], [44]), equals("A lot!"));
+ when(mock.typeParameterizedFn(typed(any), [43]))
+ .thenReturn("A bunch!");
+ expect(mock.typeParameterizedFn([42], [43]), equals("A bunch!"));
+ });
+ test("should throw when [typed] used alongside [null].", () {
+ expect(() => when(mock.typeParameterizedFn(typed(any), null, typed(any))),
+ throwsArgumentError);
+ expect(() => when(mock.typeParameterizedFn(typed(any), typed(any), null)),
+ throwsArgumentError);
+ });
+ test("should mock method when [typed] used alongside matched [null].", () {
+ when(mock.typeParameterizedFn(
+ typed(any), argThat(equals(null)), typed(any)))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedFn([42], null, [44]), equals("A lot!"));
+ });
+ test("should mock method with named, typed arg matcher", () {
+ when(mock.typeParameterizedNamedFn(
+ typed(any), [43], y: typed(any, named: "y")))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedNamedFn([42], [43], y: [44]), equals("A lot!"));
+ });
+ test("should mock method with named, typed arg matcher and an arg matcher", () {
+ when(
+ mock.typeParameterizedNamedFn(
+ typed(any), [43],
+ y: typed(any, named: "y"), z: argThat(contains(45))))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedNamedFn([42], [43], y: [44], z: [45]),
+ equals("A lot!"));
+ });
+ test("should mock method with named, typed arg matcher and a regular arg", () {
+ when(
+ mock.typeParameterizedNamedFn(
+ typed(any), [43],
+ y: typed(any, named: "y"), z: [45]))
+ .thenReturn("A lot!");
+ expect(mock.typeParameterizedNamedFn([42], [43], y: [44], z: [45]),
+ equals("A lot!"));
+ });
+ test("should throw when [typed] used as a named arg, without `named:`", () {
+ expect(() => when(mock.typeParameterizedNamedFn(
+ typed(any), [43], y: typed(any))),
+ throwsArgumentError);
+ });
+ test("should throw when [typed] used as a positional arg, with `named:`", () {
+ expect(() => when(mock.typeParameterizedNamedFn(
+ typed(any), typed(any, named: "y"))),
+ throwsArgumentError);
+ });
+ test("should throw when [typed] used as a named arg, with the wrong `named:`", () {
+ expect(() => when(mock.typeParameterizedNamedFn(
+ typed(any), [43], y: typed(any, named: "z"))),
+ throwsArgumentError);
+ });
});
group("verify()", () {
@@ -319,6 +408,17 @@
});
verify(mock.setter = "A");
});
+ test("should verify method with typed arg matchers", () {
+ mock.typeParameterizedFn([42], [43]);
+ verify(mock.typeParameterizedFn(typed(any), typed(any)));
+ });
+ test("should verify method with argument capturer", () {
+ mock.typeParameterizedFn([50], [17]);
+ mock.typeParameterizedFn([100], [17]);
+ expect(verify(mock.typeParameterizedFn(
+ typed(captureAny), [17])).captured,
+ equals([[50], [100]]));
+ });
});
group("verify() qualifies", () {
group("unqualified as at least one", () {
@@ -478,7 +578,9 @@
});
test("should captureOut list arguments", () {
mock.methodWithListArgs([42]);
- expect(verify(mock.methodWithListArgs(captureAny)).captured.single, equals([42]));
+ expect(verify(
+ mock.methodWithListArgs(captureAny)).captured.single,
+ equals([42]));
});
test("should captureOut multiple arguments", () {
mock.methodWithPositionalArgs(1, 2);