Skip to content

Commit e32f79a

Browse files
committed
Add strong mode-compliant 'typed' API
1 parent 2513f9e commit e32f79a

File tree

3 files changed

+357
-3
lines changed

3 files changed

+357
-3
lines changed

README.md

Lines changed: 87 additions & 2 deletions
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/src/mock.dart

Lines changed: 151 additions & 0 deletions
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 = _reconstituteInvocation(invocation);
38+
}
3439
if (_whenInProgress) {
3540
_whenCall = new _WhenCall(this, invocation);
3641
return null;
@@ -55,6 +60,132 @@ class Mock {
5560
String toString() => _givenName != null ? _givenName : runtimeType.toString();
5661
}
5762

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

434+
/*=T*/ typed/*<T>*/(_ArgMatcher matcher, {String name}) {
435+
if (name == null) {
436+
_typedArgs.add(matcher);
437+
} else {
438+
_typedNamedArgs[name] = matcher;
439+
}
440+
return null;
441+
}
442+
303443
class VerificationResult {
304444
List captured = [];
305445
int callCount;
@@ -413,3 +553,14 @@ void logInvocations(List<Mock> mocks) {
413553
print(inv.toString());
414554
});
415555
}
556+
557+
/// Should only be used during Mockito testing.
558+
void resetMockitoState() {
559+
_whenInProgress = false;
560+
_verificationInProgress = false;
561+
_whenCall = null;
562+
_verifyCalls.clear();
563+
_capturedArgs.clear();
564+
_typedArgs.clear();
565+
_typedNamedArgs.clear();
566+
}

0 commit comments

Comments
 (0)