diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1f1506..9534f53e 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 36e93160..d5e362ce 100644 --- a/README.md +++ b/README.md @@ -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"); @@ -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`), 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 foods, [List mixins]) => true; + int walk(List places, {Map 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 (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 71b8d9ce..c1bf3860 100644 --- a/lib/src/mock.dart +++ b/lib/src/mock.dart @@ -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 _typedNamedArgs = {}; // Hidden from the public API, used by spy.dart. void setDefaultResponse(Mock mock, dynamic defaultResponse) { @@ -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; @@ -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 namedArguments; + final List 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 _reconstituteNamedArgs(Invocation invocation) { + var namedArguments = {}; + 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 _reconstitutePositionalArgs(Invocation invocation) { + var positionalArguments = []; + 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 @@ get captureAny => new _ArgMatcher(anything, true); captureThat(Matcher matcher) => new _ArgMatcher(matcher, true); argThat(Matcher matcher) => new _ArgMatcher(matcher, false); +/*=T*/ typed/**/(_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 @@ void logInvocations(List 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(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 4cdcff5e..be8576c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: mockito -version: 0.11.1 +version: 1.0.0 author: Dmitriy Fibulwinter 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 03a2ed95..fe20df24 100644 --- a/test/mockito_test.dart +++ b/test/mockito_test.dart @@ -9,6 +9,14 @@ class RealClass { 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 w, List x, [List y, List z]) => "Real"; + // "SpecialNamedArgs" here means type-parameterized, named args. But that + // makes for a long method name. + String typeParameterizedNamedFn(List w, List x, {List y, List z}) => + "Real"; String get getter => "Real"; void set setter(String arg) { throw new StateError("I must be mocked"); @@ -55,6 +63,12 @@ void main() { 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 @@ void main() { 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 @@ void main() { }); 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 @@ void main() { }); 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);