Skip to content

Commit e6b4976

Browse files
committed
Add strong mode-compliant 'typed' API
1 parent aa9e03b commit e6b4976

File tree

3 files changed

+340
-3
lines changed

3 files changed

+340
-3
lines changed

README.md

+87-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,91 @@ 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/*<List<String>>*/(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/*<List<String>>*/(any), typed/*<List<String>>*/(any)))
206+
.thenReturn(true);
207+
when(cat.eatFood(typed/*<List<String>>*/(argThat(contains("fish")))))
208+
.thenReturn(true);
209+
```
210+
211+
Named args require one more component: `typed` needs to know what named argument it is
212+
being passed into:
213+
214+
```dart
215+
when(cat.walk(
216+
typed/*<List<String>>*/(any),
217+
gaits: typed/*<Map<String, String>>*/(any), name: 'gaits')).thenReturn(true);
218+
```
219+
220+
Note the `name` argument. Mockito should fail gracefully if you forget to name a `typed`
221+
call passed in as a named argument, or name the argument incorrectly.
222+
223+
One more note about the `typed` API: you cannot mix `typed` arguments with `null`
224+
arguments:
225+
226+
```dart
227+
when(cat.eatFood(null, typed/*<List<String>>*/(any))).thenReturn(true) // Throws!
228+
when(cat.eatFood(
229+
argThat(equals(null)),
230+
typed/*<List<String>>*/(any))).thenReturn(true); // Works.
231+
```
232+
233+
[Strong mode]: https://github.com/dart-lang/dev_compiler/blob/master/STRONG_MODE.md
234+
150235
## How it works
151236
The basics of the `Mock` class are nothing special: It uses `noSuchMethod` to catch
152237
all method invocations, and returns the value that you have configured beforehand with

lib/mockito.dart

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

1517
class Mock {
1618
final List<RealCall> _realCalls = <RealCall>[];
@@ -24,6 +26,9 @@ class Mock {
2426
}
2527

2628
dynamic noSuchMethod(Invocation invocation) {
29+
if (_typedArgs.isNotEmpty || _typedNamedArgs.isNotEmpty) {
30+
invocation = _reconstituteInvocation(invocation);
31+
}
2732
if (_whenInProgress) {
2833
_whenCall = new _WhenCall(this, invocation);
2934
return null;
@@ -48,6 +53,119 @@ class Mock {
4853
String toString() => _givenName != null ? _givenName : runtimeType.toString();
4954
}
5055

56+
// Return a new [Invocation], reconstituted from [invocation], [_typedArgs],
57+
// and [_typedNamedArgs].
58+
Invocation _reconstituteInvocation(Invocation invocation) {
59+
var newInvocation = new FakeInvocation(invocation);
60+
_typedArgs.clear();
61+
_typedNamedArgs.clear();
62+
return newInvocation;
63+
}
64+
65+
/// An Invocation class with a functional constructor.
66+
class FakeInvocation extends Invocation {
67+
final Symbol memberName;
68+
final Map<Symbol, dynamic> namedArguments;
69+
final List<dynamic> positionalArguments;
70+
final bool isGetter;
71+
final bool isMethod;
72+
final bool isSetter;
73+
74+
factory FakeInvocation(Invocation invocation) {
75+
if (_typedArgs.isEmpty && _typedNamedArgs.isEmpty) {
76+
return null;
77+
}
78+
var positionalArguments = <dynamic>[];
79+
var namedArguments = <Symbol, dynamic>{};
80+
81+
// Handle named arguments first, so that we can provide useful errors for
82+
// the various bad states. If all is well with the named arguments, then we
83+
// can process the positional arguments, and resort to more general errors
84+
// if the state is still bad.
85+
var _typedNamedArgSymbols = _typedNamedArgs.keys.map((name) => new Symbol(name));
86+
invocation.namedArguments.forEach((name, arg) {
87+
if (arg == null) {
88+
if (!_typedNamedArgSymbols.contains(name)) {
89+
// Incorrect usage of [typed], something like:
90+
// `when(obj.fn(a: typed(any)))`.
91+
throw new ArgumentError(
92+
'A typed argument was passed in as a named argument named "$name", '
93+
'but did not a value for its name. Each typed argument that is '
94+
'passed as a named argument needs to specify the `name` argument. '
95+
'For example: `when(obj.fn(x: typed(any, name: "x")))`.');
96+
}
97+
} else {
98+
// Add each real named argument that was _not_ passed with [typed].
99+
namedArguments[name] = arg;
100+
}
101+
});
102+
103+
_typedNamedArgs.forEach((name, arg) {
104+
Symbol nameSymbol = new Symbol(name);
105+
if (!invocation.namedArguments.containsKey(nameSymbol)) {
106+
// Incorrect usage of [name], something like:
107+
// `when(obj.fn(typed(any, name: 'a')))`.
108+
throw new ArgumentError(
109+
'A typed argument was declared with name $name, but was not passed '
110+
'as an argument named $name.');
111+
}
112+
if (invocation.namedArguments[nameSymbol] != null) {
113+
// Incorrect usage of [name], something like:
114+
// `when(obj.fn(a: typed(any, name: 'b'), b: "string"))`.
115+
throw new ArgumentError(
116+
'A typed argument was declared with name $name, but a different '
117+
'value (${invocation.namedArguments[nameSymbol]}) was passed as '
118+
'$name.');
119+
}
120+
namedArguments[nameSymbol] = arg;
121+
});
122+
123+
var nullPositionalArguments =
124+
invocation.positionalArguments.where((arg) => arg == null);
125+
if (_typedArgs.length != nullPositionalArguments.length) {
126+
throw new ArgumentError(
127+
'null arguments are not allowed alongside typed(); use '
128+
'"typed(eq(null))"');
129+
}
130+
int i = 0;
131+
int j = 0;
132+
while (i < _typedArgs.length && j < invocation.positionalArguments.length) {
133+
var arg = _typedArgs[i];
134+
if (invocation.positionalArguments[j] == null) {
135+
// [typed] was used; add the [_ArgMatcher] given to [typed].
136+
positionalArguments.add(arg);
137+
i++;
138+
j++;
139+
} else {
140+
// [typed] was not used; add the [_ArgMatcher] from [invocation].
141+
positionalArguments.add(invocation.positionalArguments[j]);
142+
j++;
143+
}
144+
}
145+
while (j < invocation.positionalArguments.length) {
146+
// Some trailing non-[typed] arguments.
147+
positionalArguments.add(invocation.positionalArguments[j]);
148+
j++;
149+
}
150+
151+
return new FakeInvocation._(
152+
invocation.memberName,
153+
positionalArguments,
154+
namedArguments,
155+
invocation.isGetter,
156+
invocation.isMethod,
157+
invocation.isSetter);
158+
}
159+
160+
FakeInvocation._(
161+
this.memberName,
162+
this.positionalArguments,
163+
this.namedArguments,
164+
this.isGetter,
165+
this.isMethod,
166+
this.isSetter);
167+
}
168+
51169
named(var mock, {String name, int hashCode}) => mock
52170
.._givenName = name
53171
.._givenHashCode = hashCode;
@@ -291,6 +409,15 @@ get captureAny => new _ArgMatcher(anything, true);
291409
captureThat(Matcher matcher) => new _ArgMatcher(matcher, true);
292410
argThat(Matcher matcher) => new _ArgMatcher(matcher, false);
293411

412+
/*=T*/ typed/*<T>*/(_ArgMatcher matcher, {String name}) {
413+
if (name == null) {
414+
_typedArgs.add(matcher);
415+
} else {
416+
_typedNamedArgs[name] = matcher;
417+
}
418+
return null;
419+
}
420+
294421
class VerificationResult {
295422
List captured = [];
296423
int callCount;
@@ -404,3 +531,14 @@ void logInvocations(List<Mock> mocks) {
404531
print(inv.toString());
405532
});
406533
}
534+
535+
/// Should only be used during Mockito testing.
536+
void resetMockitoState() {
537+
_whenInProgress = false;
538+
_verificationInProgress = false;
539+
_whenCall = null;
540+
_verifyCalls.clear();
541+
_capturedArgs.clear();
542+
_typedArgs.clear();
543+
_typedNamedArgs.clear();
544+
}

0 commit comments

Comments
 (0)