Skip to content

Commit 625f0f4

Browse files
authored
Merge pull request #26 from srawlins/typed-api
Add strong mode-compliant 'typed' API
2 parents 2513f9e + beb8b26 commit 625f0f4

File tree

5 files changed

+355
-4
lines changed

5 files changed

+355
-4
lines changed

CHANGELOG.md

+11
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
## 1.0.0
2+
3+
* Add a new `typed` API that is compatible with Dart Dev Compiler; documented in
4+
README.md.
5+
6+
## 0.11.1
7+
8+
* Move the reflection-based `spy` code into a private source file. Now
9+
`package:mockito/mockito.dart` includes this reflection-based API, and a new
10+
`package:mockito/mockito_no_mirrors.dart` doesn't require mirrors.
11+
112
## 0.11.0
213

314
* 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))`.

README.md

+84-2
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,11 @@ verifyNoMoreInteractions(cat);
126126
```dart
127127
//simple capture
128128
cat.eatFood("Fish");
129-
expect(verify(cat.eatFood(capture)).captured.single, "Fish");
129+
expect(verify(cat.eatFood(captureAny)).captured.single, "Fish");
130130
//capture multiple calls
131131
cat.eatFood("Milk");
132132
cat.eatFood("Fish");
133-
expect(verify(cat.eatFood(capture)).captured, ["Milk", "Fish"]);
133+
expect(verify(cat.eatFood(captureAny)).captured, ["Milk", "Fish"]);
134134
//conditional capture
135135
cat.eatFood("Milk");
136136
cat.eatFood("Fish");
@@ -147,6 +147,88 @@ expect(cat.sound(), "Purr");
147147
//using real object
148148
expect(cat.lives, 9);
149149
```
150+
151+
## Strong mode compliance
152+
153+
Unfortunately, the use of the arg matchers in mock method calls (like `cat.eatFood(any)`)
154+
violates the [Strong mode] type system. Specifically, if the method signature of a mocked
155+
method has a parameter with a parameterized type (like `List<int>`), then passing `any` or
156+
`argThat` will result in a Strong mode warning:
157+
158+
> [warning] Unsound implicit cast from dynamic to List&lt;int>
159+
160+
In order to write Strong mode-compliant tests with Mockito, you might need to use `typed`,
161+
annotating it with a type parameter comment. Let's use a slightly different `Cat` class to
162+
show some examples:
163+
164+
```dart
165+
class Cat {
166+
bool eatFood(List<String> foods, [List<String> mixins]) => true;
167+
int walk(List<String> places, {Map<String, String> gaits}) => 0;
168+
}
169+
170+
class MockCat extends Mock implements Cat {}
171+
172+
var cat = new MockCat();
173+
```
174+
175+
OK, what if we try to stub using `any`:
176+
177+
```dart
178+
when(cat.eatFood(any)).thenReturn(true);
179+
```
180+
181+
Let's analyze this code:
182+
183+
```
184+
$ dartanalyzer --strong test/cat_test.dart
185+
Analyzing [lib/cat_test.dart]...
186+
[warning] Unsound implicit cast from dynamic to List<String> (test/cat_test.dart, line 12, col 20)
187+
1 warning found.
188+
```
189+
190+
This code is not Strong mode-compliant. Let's change it to use `typed`:
191+
192+
```dart
193+
when(cat.eatFood(typed(any)))
194+
```
195+
196+
```
197+
$ dartanalyzer --strong test/cat_test.dart
198+
Analyzing [lib/cat_test.dart]...
199+
No issues found
200+
```
201+
202+
Great! A little ugly, but it works. Here are some more examples:
203+
204+
```dart
205+
when(cat.eatFood(typed(any), typed(any))).thenReturn(true);
206+
when(cat.eatFood(typed(argThat(contains("fish"))))).thenReturn(true);
207+
```
208+
209+
Named args require one more component: `typed` needs to know what named argument it is
210+
being passed into:
211+
212+
```dart
213+
when(cat.walk(typed(any), gaits: typed(any, named: 'gaits')))
214+
.thenReturn(true);
215+
```
216+
217+
Note the `named` argument. Mockito should fail gracefully if you forget to name a `typed`
218+
call passed in as a named argument, or name the argument incorrectly.
219+
220+
One more note about the `typed` API: you cannot mix `typed` arguments with `null`
221+
arguments:
222+
223+
```dart
224+
when(cat.eatFood(null, typed(any))).thenReturn(true); // Throws!
225+
when(cat.eatFood(
226+
argThat(equals(null)),
227+
typed(any))).thenReturn(true); // Works.
228+
```
229+
230+
[Strong mode]: https://github.com/dart-lang/dev_compiler/blob/master/STRONG_MODE.md
231+
150232
## How it works
151233
The basics of the `Mock` class are nothing special: It uses `noSuchMethod` to catch
152234
all method invocations, and returns the value that you have configured beforehand with

lib/src/mock.dart

+156
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ _WhenCall _whenCall = null;
1010
final List<_VerifyCall> _verifyCalls = <_VerifyCall>[];
1111
final _TimeStampProvider _timer = new _TimeStampProvider();
1212
final List _capturedArgs = [];
13+
final List<_ArgMatcher> _typedArgs = <_ArgMatcher>[];
14+
final Map<String, _ArgMatcher> _typedNamedArgs = <String, _ArgMatcher>{};
1315

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

3335
dynamic noSuchMethod(Invocation invocation) {
36+
if (_typedArgs.isNotEmpty || _typedNamedArgs.isNotEmpty) {
37+
invocation = new _InvocationForTypedArguments(invocation);
38+
}
3439
if (_whenInProgress) {
3540
_whenCall = new _WhenCall(this, invocation);
3641
return null;
@@ -55,6 +60,137 @@ class Mock {
5560
String toString() => _givenName != null ? _givenName : runtimeType.toString();
5661
}
5762

63+
/// An Invocation implementation that takes arguments from [_typedArgs] and
64+
/// [_typedNamedArgs].
65+
class _InvocationForTypedArguments extends Invocation {
66+
final Symbol memberName;
67+
final Map<Symbol, dynamic> namedArguments;
68+
final List<dynamic> positionalArguments;
69+
final bool isGetter;
70+
final bool isMethod;
71+
final bool isSetter;
72+
73+
factory _InvocationForTypedArguments(Invocation invocation) {
74+
if (_typedArgs.isEmpty && _typedNamedArgs.isEmpty) {
75+
throw new StateError(
76+
"_InvocationForTypedArguments called when no typed calls have been saved.");
77+
}
78+
79+
// Handle named arguments first, so that we can provide useful errors for
80+
// the various bad states. If all is well with the named arguments, then we
81+
// can process the positional arguments, and resort to more general errors
82+
// if the state is still bad.
83+
var namedArguments = _reconstituteNamedArgs(invocation);
84+
var positionalArguments = _reconstitutePositionalArgs(invocation);
85+
86+
_typedArgs.clear();
87+
_typedNamedArgs.clear();
88+
89+
return new _InvocationForTypedArguments._(
90+
invocation.memberName,
91+
positionalArguments,
92+
namedArguments,
93+
invocation.isGetter,
94+
invocation.isMethod,
95+
invocation.isSetter);
96+
}
97+
98+
// Reconstitutes the named arguments in an invocation from [_typedNamedArgs].
99+
//
100+
// The namedArguments in [invocation] which are null should be represented
101+
// by a stored value in [_typedNamedArgs]. The null presumably came from
102+
// [typed].
103+
static Map<Symbol,dynamic> _reconstituteNamedArgs(Invocation invocation) {
104+
var namedArguments = <Symbol, dynamic>{};
105+
var _typedNamedArgSymbols = _typedNamedArgs.keys.map((name) => new Symbol(name));
106+
107+
// Iterate through [invocation]'s named args, validate them, and add them
108+
// to the return map.
109+
invocation.namedArguments.forEach((name, arg) {
110+
if (arg == null) {
111+
if (!_typedNamedArgSymbols.contains(name)) {
112+
// Incorrect usage of [typed], something like:
113+
// `when(obj.fn(a: typed(any)))`.
114+
throw new ArgumentError(
115+
'A typed argument was passed in as a named argument named "$name", '
116+
'but did not pass a value for `named`. Each typed argument that is '
117+
'passed as a named argument needs to specify the `named` argument. '
118+
'For example: `when(obj.fn(x: typed(any, named: "x")))`.');
119+
}
120+
} else {
121+
// Add each real named argument that was _not_ passed with [typed].
122+
namedArguments[name] = arg;
123+
}
124+
});
125+
126+
// Iterate through the stored named args (stored with [typed]), validate
127+
// them, and add them to the return map.
128+
_typedNamedArgs.forEach((name, arg) {
129+
Symbol nameSymbol = new Symbol(name);
130+
if (!invocation.namedArguments.containsKey(nameSymbol)) {
131+
throw new ArgumentError(
132+
'A typed argument was declared as named $name, but was not passed '
133+
'as an argument named $name.\n\n'
134+
'BAD: when(obj.fn(typed(any, named: "a")))\n'
135+
'GOOD: when(obj.fn(a: typed(any, named: "a")))');
136+
}
137+
if (invocation.namedArguments[nameSymbol] != null) {
138+
throw new ArgumentError(
139+
'A typed argument was declared as named $name, but a different '
140+
'value (${invocation.namedArguments[nameSymbol]}) was passed as '
141+
'$name.\n\n'
142+
'BAD: when(obj.fn(b: typed(any, name: "a")))\n'
143+
'GOOD: when(obj.fn(b: typed(any, name: "b")))');
144+
}
145+
namedArguments[nameSymbol] = arg;
146+
});
147+
148+
return namedArguments;
149+
}
150+
151+
static List<dynamic> _reconstitutePositionalArgs(Invocation invocation) {
152+
var positionalArguments = <dynamic>[];
153+
var nullPositionalArguments =
154+
invocation.positionalArguments.where((arg) => arg == null);
155+
if (_typedArgs.length != nullPositionalArguments.length) {
156+
throw new ArgumentError(
157+
'null arguments are not allowed alongside typed(); use '
158+
'"typed(eq(null))"');
159+
}
160+
int typedIndex = 0;
161+
int positionalIndex = 0;
162+
while (typedIndex < _typedArgs.length &&
163+
positionalIndex < invocation.positionalArguments.length) {
164+
var arg = _typedArgs[typedIndex];
165+
if (invocation.positionalArguments[positionalIndex] == null) {
166+
// [typed] was used; add the [_ArgMatcher] given to [typed].
167+
positionalArguments.add(arg);
168+
typedIndex++;
169+
positionalIndex++;
170+
} else {
171+
// [typed] was not used; add the [_ArgMatcher] from [invocation].
172+
positionalArguments.add(invocation.positionalArguments[positionalIndex]);
173+
positionalIndex++;
174+
}
175+
}
176+
while (positionalIndex < invocation.positionalArguments.length) {
177+
// Some trailing non-[typed] arguments.
178+
positionalArguments.add(invocation.positionalArguments[positionalIndex]);
179+
positionalIndex++;
180+
}
181+
182+
return positionalArguments;
183+
}
184+
185+
_InvocationForTypedArguments._(
186+
this.memberName,
187+
this.positionalArguments,
188+
this.namedArguments,
189+
this.isGetter,
190+
this.isMethod,
191+
this.isSetter);
192+
}
193+
58194
named(var mock, {String name, int hashCode}) => mock
59195
.._givenName = name
60196
.._givenHashCode = hashCode;
@@ -300,6 +436,15 @@ get captureAny => new _ArgMatcher(anything, true);
300436
captureThat(Matcher matcher) => new _ArgMatcher(matcher, true);
301437
argThat(Matcher matcher) => new _ArgMatcher(matcher, false);
302438

439+
/*=T*/ typed/*<T>*/(_ArgMatcher matcher, {String named}) {
440+
if (named == null) {
441+
_typedArgs.add(matcher);
442+
} else {
443+
_typedNamedArgs[named] = matcher;
444+
}
445+
return null;
446+
}
447+
303448
class VerificationResult {
304449
List captured = [];
305450
int callCount;
@@ -413,3 +558,14 @@ void logInvocations(List<Mock> mocks) {
413558
print(inv.toString());
414559
});
415560
}
561+
562+
/// Should only be used during Mockito testing.
563+
void resetMockitoState() {
564+
_whenInProgress = false;
565+
_verificationInProgress = false;
566+
_whenCall = null;
567+
_verifyCalls.clear();
568+
_capturedArgs.clear();
569+
_typedArgs.clear();
570+
_typedNamedArgs.clear();
571+
}

pubspec.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: mockito
2-
version: 0.11.1
2+
version: 1.0.0
33
author: Dmitriy Fibulwinter <[email protected]>
44
description: A mock framework inspired by Mockito.
55
homepage: https://github.com/fibulwinter/dart-mockito

0 commit comments

Comments
 (0)