From 3145e114e2b78888227ed159bd2603d02d5d0d1a Mon Sep 17 00:00:00 2001 From: srawlins Date: Tue, 19 Jul 2022 14:31:58 -0400 Subject: [PATCH] Allow mixing types into generated mocks. This is primarily here to support private field promotion: https://github.com/dart-lang/language/issues/2020 Also discussion at https://github.com/dart-lang/language/issues/2275 The broad stroke is that users may need to start declaring little mixins next to their base classes with implementations for this or that private API which is (intentionally or not) accessed against a mock instance during a test. Fixes https://github.com/dart-lang/mockito/issues/342 PiperOrigin-RevId: 461933542 --- pkgs/mockito/lib/annotations.dart | 6 +- pkgs/mockito/lib/src/builder.dart | 41 +++++- .../test/builder/custom_mocks_test.dart | 135 +++++++++++++++++- pkgs/mockito/test/end2end/foo.dart | 15 ++ .../test/end2end/generated_mocks_test.dart | 42 ++++-- 5 files changed, 220 insertions(+), 19 deletions(-) diff --git a/pkgs/mockito/lib/annotations.dart b/pkgs/mockito/lib/annotations.dart index b2c1504f7..32281c220 100644 --- a/pkgs/mockito/lib/annotations.dart +++ b/pkgs/mockito/lib/annotations.dart @@ -69,6 +69,8 @@ class GenerateMocks { class MockSpec { final Symbol? mockName; + final List mixins; + final bool returnNullOnMissingStub; final Set unsupportedMembers; @@ -103,8 +105,10 @@ class MockSpec { /// as a legal return value. const MockSpec({ Symbol? as, + List mixingIn = const [], this.returnNullOnMissingStub = false, this.unsupportedMembers = const {}, this.fallbackGenerators = const {}, - }) : mockName = as; + }) : mockName = as, + mixins = mixingIn; } diff --git a/pkgs/mockito/lib/src/builder.dart b/pkgs/mockito/lib/src/builder.dart index d958d2eb3..95c7d0675 100644 --- a/pkgs/mockito/lib/src/builder.dart +++ b/pkgs/mockito/lib/src/builder.dart @@ -153,13 +153,16 @@ $rawOutput .whereType() .forEach(addTypesFrom); // For a type like `Foo extends Bar`, add the `Baz`. - for (var supertype in type.allSupertypes) { + for (final supertype in type.allSupertypes) { addTypesFrom(supertype); } } - for (var mockTarget in mockTargets) { + for (final mockTarget in mockTargets) { addTypesFrom(mockTarget.classType); + for (final mixinTarget in mockTarget.mixins) { + addTypesFrom(mixinTarget); + } } final typeUris = {}; @@ -359,6 +362,8 @@ class _MockTarget { /// The desired name of the mock class. final String mockName; + final List mixins; + final bool returnNullOnMissingStub; final Set unsupportedMembers; @@ -368,6 +373,7 @@ class _MockTarget { _MockTarget( this.classType, this.mockName, { + required this.mixins, required this.returnNullOnMissingStub, required this.unsupportedMembers, required this.fallbackGenerators, @@ -453,6 +459,7 @@ class _MockTargetGatherer { mockTargets.add(_MockTarget( declarationType, mockName, + mixins: [], returnNullOnMissingStub: false, unsupportedMembers: {}, fallbackGenerators: {}, @@ -480,6 +487,27 @@ class _MockTargetGatherer { } final mockName = mockSpec.getField('mockName')!.toSymbolValue() ?? 'Mock${type.element.name}'; + final mixins = []; + for (final m in mockSpec.getField('mixins')!.toListValue()!) { + final typeToMixin = m.toTypeValue(); + if (typeToMixin == null) { + throw InvalidMockitoAnnotationException( + 'The "mixingIn" argument includes a non-type: $m'); + } + if (typeToMixin.isDynamic) { + throw InvalidMockitoAnnotationException( + 'Mockito cannot mix `dynamic` into a mock class'); + } + final mixinInterfaceType = + _determineDartType(typeToMixin, entryLib.typeProvider); + if (!mixinInterfaceType.interfaces.contains(type)) { + throw InvalidMockitoAnnotationException('The "mixingIn" type, ' + '${typeToMixin.getDisplayString(withNullability: false)}, must ' + 'implement the class to mock, ${typeToMock.getDisplayString(withNullability: false)}'); + } + mixins.add(mixinInterfaceType); + } + final returnNullOnMissingStub = mockSpec.getField('returnNullOnMissingStub')!.toBoolValue()!; final unsupportedMembers = { @@ -492,6 +520,7 @@ class _MockTargetGatherer { mockTargets.add(_MockTarget( type, mockName, + mixins: mixins, returnNullOnMissingStub: returnNullOnMissingStub, unsupportedMembers: unsupportedMembers, fallbackGenerators: @@ -930,6 +959,14 @@ class _MockClassInfo { typeArguments.add(refer(typeParameter.name)); } } + for (final mixin in mockTarget.mixins) { + cBuilder.mixins.add(TypeReference((b) { + b + ..symbol = mixin.name + ..url = _typeImport(mixin.element) + ..types.addAll(mixin.typeArguments.map(_typeReference)); + })); + } cBuilder.implements.add(TypeReference((b) { b ..symbol = classToMock.name diff --git a/pkgs/mockito/test/builder/custom_mocks_test.dart b/pkgs/mockito/test/builder/custom_mocks_test.dart index 2d3e7c16e..23210f4b9 100644 --- a/pkgs/mockito/test/builder/custom_mocks_test.dart +++ b/pkgs/mockito/test/builder/custom_mocks_test.dart @@ -35,6 +35,8 @@ class GenerateMocks { class MockSpec { final Symbol mockName; + final List mixins; + final bool returnNullOnMissingStub; final Set unsupportedMembers; @@ -43,10 +45,12 @@ class MockSpec { const MockSpec({ Symbol? as, + List mixingIn = const [], this.returnNullOnMissingStub = false, this.unsupportedMembers = const {}, this.fallbackGenerators = const {}, - }) : mockName = as; + }) : mockName = as, + mixins = mixingIn; } ''' }; @@ -95,7 +99,7 @@ void main() { var packageConfig = PackageConfig([ Package('foo', Uri.file('/foo/'), packageUriRoot: Uri.file('/foo/lib/'), - languageVersion: LanguageVersion(2, 12)) + languageVersion: LanguageVersion(2, 15)) ]); await testBuilder(buildMocks(BuilderOptions({})), sourceAssets, writer: writer, packageConfig: packageConfig); @@ -310,6 +314,74 @@ void main() { contains('class MockBFoo extends _i1.Mock implements _i3.Foo')); }); + test('generates a mock class with a declared mixin', () async { + var mocksContent = await buildWithNonNullable({ + ...annotationsAsset, + 'foo|lib/foo.dart': dedent(''' + class Foo {} + + class FooMixin implements Foo {} + '''), + 'foo|test/foo_test.dart': ''' + import 'package:foo/foo.dart'; + import 'package:mockito/annotations.dart'; + @GenerateMocks([], customMocks: [MockSpec(mixingIn: [FooMixin])]) + void main() {} + ''' + }); + expect( + mocksContent, + contains( + 'class MockFoo extends _i1.Mock with _i2.FooMixin implements _i2.Foo {'), + ); + }); + + test('generates a mock class with multiple declared mixins', () async { + var mocksContent = await buildWithNonNullable({ + ...annotationsAsset, + 'foo|lib/foo.dart': dedent(''' + class Foo {} + + class Mixin1 implements Foo {} + class Mixin2 implements Foo {} + '''), + 'foo|test/foo_test.dart': ''' + import 'package:foo/foo.dart'; + import 'package:mockito/annotations.dart'; + @GenerateMocks([], customMocks: [MockSpec(mixingIn: [Mixin1, Mixin2])]) + void main() {} + ''' + }); + expect( + mocksContent, + contains( + 'class MockFoo extends _i1.Mock with _i2.Mixin1, _i2.Mixin2 implements _i2.Foo {'), + ); + }); + + test('generates a mock class with a declared mixin with a type arg', + () async { + var mocksContent = await buildWithNonNullable({ + ...annotationsAsset, + 'foo|lib/foo.dart': dedent(''' + class Foo {} + + class FooMixin implements Foo {} + '''), + 'foo|test/foo_test.dart': ''' + import 'package:foo/foo.dart'; + import 'package:mockito/annotations.dart'; + @GenerateMocks([], customMocks: [MockSpec>(mixingIn: [FooMixin])]) + void main() {} + ''' + }); + expect( + mocksContent, + contains( + 'class MockFoo extends _i1.Mock with _i2.FooMixin implements _i2.Foo {'), + ); + }); + test( 'generates a mock class which uses the old behavior of returning null on ' 'missing stubs', () async { @@ -804,6 +876,65 @@ void main() { ); }); + test('throws when MockSpec mixes in dynamic', () async { + _expectBuilderThrows( + assets: { + ...annotationsAsset, + 'foo|lib/foo.dart': dedent(''' + class Foo {} + '''), + 'foo|test/foo_test.dart': dedent(''' + import 'package:mockito/annotations.dart'; + import 'package:foo/foo.dart'; + @GenerateMocks([], customMocks: [MockSpec(mixingIn: [dynamic])]) + void main() {} + '''), + }, + message: contains('Mockito cannot mix `dynamic` into a mock class'), + ); + }); + + test('throws when MockSpec mixes in a private type', () async { + _expectBuilderThrows( + assets: { + ...annotationsAsset, + 'foo|lib/foo.dart': dedent(''' + class Foo {} + '''), + 'foo|test/foo_test.dart': dedent(''' + import 'package:mockito/annotations.dart'; + import 'package:foo/foo.dart'; + @GenerateMocks([], customMocks: [MockSpec(mixingIn: [_FooMixin])]) + void main() {} + + mixin _FooMixin implements Foo {} + '''), + }, + message: contains('Mockito cannot mock a private type: _FooMixin'), + ); + }); + + test('throws when MockSpec mixes in a non-mixinable type', () async { + _expectBuilderThrows( + assets: { + ...annotationsAsset, + 'foo|lib/foo.dart': dedent(''' + class Foo {} + '''), + 'foo|test/foo_test.dart': dedent(''' + import 'package:mockito/annotations.dart'; + import 'package:foo/foo.dart'; + @GenerateMocks([], customMocks: [MockSpec(mixingIn: [FooMixin])]) + void main() {} + + mixin FooMixin {} + '''), + }, + message: contains( + 'The "mixingIn" type, FooMixin, must implement the class to mock, Foo'), + ); + }); + test('given a pre-non-nullable library, does not override any members', () async { await testPreNonNullable( diff --git a/pkgs/mockito/test/end2end/foo.dart b/pkgs/mockito/test/end2end/foo.dart index 26893c711..f3f9f13a6 100644 --- a/pkgs/mockito/test/end2end/foo.dart +++ b/pkgs/mockito/test/end2end/foo.dart @@ -31,3 +31,18 @@ abstract class Baz { S Function(S) returnsGenericFunction(); S get typeVariableField; } + +class HasPrivate { + Object? _p; + + Object? get p => _p; +} + +void setPrivate(HasPrivate hasPrivate) { + hasPrivate._p = 7; +} + +mixin HasPrivateMixin implements HasPrivate { + @override + Object? _p; +} diff --git a/pkgs/mockito/test/end2end/generated_mocks_test.dart b/pkgs/mockito/test/end2end/generated_mocks_test.dart index c18e1add0..502c2ff48 100644 --- a/pkgs/mockito/test/end2end/generated_mocks_test.dart +++ b/pkgs/mockito/test/end2end/generated_mocks_test.dart @@ -25,20 +25,27 @@ T Function(T) returnsGenericFunctionShim() => (T _) => null as T; ], customMocks: [ MockSpec(as: #MockFooRelaxed, returnNullOnMissingStub: true), MockSpec(as: #MockBarRelaxed, returnNullOnMissingStub: true), - MockSpec(as: #MockBazWithUnsupportedMembers, unsupportedMembers: { - #returnsTypeVariable, - #returnsBoundedTypeVariable, - #returnsTypeVariableFromTwo, - #returnsGenericFunction, - #typeVariableField, - }), - MockSpec(as: #MockBazWithFallbackGenerators, fallbackGenerators: { - #returnsTypeVariable: returnsTypeVariableShim, - #returnsBoundedTypeVariable: returnsBoundedTypeVariableShim, - #returnsTypeVariableFromTwo: returnsTypeVariableFromTwoShim, - #returnsGenericFunction: returnsGenericFunctionShim, - #typeVariableField: typeVariableFieldShim, - }), + MockSpec( + as: #MockBazWithUnsupportedMembers, + unsupportedMembers: { + #returnsTypeVariable, + #returnsBoundedTypeVariable, + #returnsTypeVariableFromTwo, + #returnsGenericFunction, + #typeVariableField, + }, + ), + MockSpec( + as: #MockBazWithFallbackGenerators, + fallbackGenerators: { + #returnsTypeVariable: returnsTypeVariableShim, + #returnsBoundedTypeVariable: returnsBoundedTypeVariableShim, + #returnsTypeVariableFromTwo: returnsTypeVariableFromTwoShim, + #returnsGenericFunction: returnsGenericFunctionShim, + #typeVariableField: typeVariableFieldShim, + }, + ), + MockSpec(mixingIn: [HasPrivateMixin]), ]) void main() { group('for a generated mock,', () { @@ -257,4 +264,11 @@ void main() { when(foo.methodWithBarArg(bar)).thenReturn('mocked result'); expect(foo.methodWithBarArg(bar), equals('mocked result')); }); + + test('a generated mock with a mixed in type can use mixed in members', () { + var hasPrivate = MockHasPrivate(); + // This should not throw, when `setPrivate` accesses a private member on + // `hasPrivate`. + setPrivate(hasPrivate); + }); }