Skip to content

Commit 0d48651

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

File tree

3 files changed

+357
-3
lines changed

3 files changed

+357
-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

+151
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,132 @@ 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+
return newInvocation;
61+
}
62+
63+
/// An Invocation implementation that allows all attributes to be passed into
64+
/// the constructor.
65+
class FakeInvocation 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 FakeInvocation(Invocation invocation) {
74+
if (_typedArgs.isEmpty && _typedNamedArgs.isEmpty) {
75+
throw new StateError("FakeInvocation called when no typed calls have been saved.");
76+
}
77+
78+
// Handle named arguments first, so that we can provide useful errors for
79+
// the various bad states. If all is well with the named arguments, then we
80+
// can process the positional arguments, and resort to more general errors
81+
// if the state is still bad.
82+
var namedArguments = _reconstituteNamedArgs(invocation);
83+
var positionalArguments = _reconstitutePositionalArgs(invocation);
84+
85+
_typedArgs.clear();
86+
_typedNamedArgs.clear();
87+
88+
return new FakeInvocation._(
89+
invocation.memberName,
90+
positionalArguments,
91+
namedArguments,
92+
invocation.isGetter,
93+
invocation.isMethod,
94+
invocation.isSetter);
95+
}
96+
97+
static Map<Symbol,dynamic> _reconstituteNamedArgs(Invocation invocation) {
98+
var namedArguments = <Symbol, dynamic>{};
99+
var _typedNamedArgSymbols = _typedNamedArgs.keys.map((name) => new Symbol(name));
100+
invocation.namedArguments.forEach((name, arg) {
101+
if (arg == null) {
102+
if (!_typedNamedArgSymbols.contains(name)) {
103+
// Incorrect usage of [typed], something like:
104+
// `when(obj.fn(a: typed(any)))`.
105+
throw new ArgumentError(
106+
'A typed argument was passed in as a named argument named "$name", '
107+
'but did not a value for its name. Each typed argument that is '
108+
'passed as a named argument needs to specify the `name` argument. '
109+
'For example: `when(obj.fn(x: typed(any, name: "x")))`.');
110+
}
111+
} else {
112+
// Add each real named argument that was _not_ passed with [typed].
113+
namedArguments[name] = arg;
114+
}
115+
});
116+
117+
_typedNamedArgs.forEach((name, arg) {
118+
Symbol nameSymbol = new Symbol(name);
119+
if (!invocation.namedArguments.containsKey(nameSymbol)) {
120+
// Incorrect usage of [name], something like:
121+
// `when(obj.fn(typed(any, name: 'a')))`.
122+
throw new ArgumentError(
123+
'A typed argument was declared with name $name, but was not passed '
124+
'as an argument named $name.');
125+
}
126+
if (invocation.namedArguments[nameSymbol] != null) {
127+
// Incorrect usage of [name], something like:
128+
// `when(obj.fn(a: typed(any, name: 'b'), b: "string"))`.
129+
throw new ArgumentError(
130+
'A typed argument was declared with name $name, but a different '
131+
'value (${invocation.namedArguments[nameSymbol]}) was passed as '
132+
'$name.');
133+
}
134+
namedArguments[nameSymbol] = arg;
135+
});
136+
137+
return namedArguments;
138+
}
139+
140+
static List<dynamic> _reconstitutePositionalArgs(Invocation invocation) {
141+
var positionalArguments = <dynamic>[];
142+
var nullPositionalArguments =
143+
invocation.positionalArguments.where((arg) => arg == null);
144+
if (_typedArgs.length != nullPositionalArguments.length) {
145+
throw new ArgumentError(
146+
'null arguments are not allowed alongside typed(); use '
147+
'"typed(eq(null))"');
148+
}
149+
int i = 0;
150+
int j = 0;
151+
while (i < _typedArgs.length && j < invocation.positionalArguments.length) {
152+
var arg = _typedArgs[i];
153+
if (invocation.positionalArguments[j] == null) {
154+
// [typed] was used; add the [_ArgMatcher] given to [typed].
155+
positionalArguments.add(arg);
156+
i++;
157+
j++;
158+
} else {
159+
// [typed] was not used; add the [_ArgMatcher] from [invocation].
160+
positionalArguments.add(invocation.positionalArguments[j]);
161+
j++;
162+
}
163+
}
164+
while (j < invocation.positionalArguments.length) {
165+
// Some trailing non-[typed] arguments.
166+
positionalArguments.add(invocation.positionalArguments[j]);
167+
j++;
168+
}
169+
170+
return positionalArguments;
171+
}
172+
173+
FakeInvocation._(
174+
this.memberName,
175+
this.positionalArguments,
176+
this.namedArguments,
177+
this.isGetter,
178+
this.isMethod,
179+
this.isSetter);
180+
}
181+
51182
named(var mock, {String name, int hashCode}) => mock
52183
.._givenName = name
53184
.._givenHashCode = hashCode;
@@ -291,6 +422,15 @@ get captureAny => new _ArgMatcher(anything, true);
291422
captureThat(Matcher matcher) => new _ArgMatcher(matcher, true);
292423
argThat(Matcher matcher) => new _ArgMatcher(matcher, false);
293424

425+
/*=T*/ typed/*<T>*/(_ArgMatcher matcher, {String name}) {
426+
if (name == null) {
427+
_typedArgs.add(matcher);
428+
} else {
429+
_typedNamedArgs[name] = matcher;
430+
}
431+
return null;
432+
}
433+
294434
class VerificationResult {
295435
List captured = [];
296436
int callCount;
@@ -404,3 +544,14 @@ void logInvocations(List<Mock> mocks) {
404544
print(inv.toString());
405545
});
406546
}
547+
548+
/// Should only be used during Mockito testing.
549+
void resetMockitoState() {
550+
_whenInProgress = false;
551+
_verificationInProgress = false;
552+
_whenCall = null;
553+
_verifyCalls.clear();
554+
_capturedArgs.clear();
555+
_typedArgs.clear();
556+
_typedNamedArgs.clear();
557+
}

0 commit comments

Comments
 (0)