Skip to content

Commit

Permalink
Make MockBuilder support build_extensions option.
Browse files Browse the repository at this point in the history
This is useful to change the destination of the generated files.
i.e: instead of having them on the same folder,
you can specify a diferent folder for the mocks.

Closes #545
  • Loading branch information
LuisDuarte1 committed Aug 15, 2023
1 parent ff79de6 commit 4ff995f
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

* Require analyzer 5.12.0, allow analyzer version 6.x;
* Add example of writing a class to mock function objects.
* Add support for the `build_extensions` build.yaml option

## 5.4.2

Expand Down
34 changes: 34 additions & 0 deletions FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,37 @@ it's done. It's very straightforward.

[`verify`]: https://pub.dev/documentation/mockito/latest/mockito/verify.html
[`verifyInOrder`]: https://pub.dev/documentation/mockito/latest/mockito/verifyInOrder.html


### How can I customize where Mockito outputs its mocks?

Mockito supports configuration of outputs by the configuration provided by the `build`
package by creating (if it doesn't exist already) the `build.yaml` at the root folder
of the project.

It uses the `build_extensions` option, which can be used to alter not only the output directory but you
can also do other filename manipulation, eg.: append/prepend strings to the filename or add another extension
to the filename.

To use `build_extensions` you can use `^` on the input string to match on the project root, and `{{}}` to capture the remaining path/filename.

You can also have multiple build_extensions options, but they can't conflict with each other.
For consistency, the output pattern must always end with `.mocks.dart` and the input pattern must always end with `.dart`

```yaml
targets:
$default:
builders:
mockito|mockBuilder:
generate_for:
options:
# build_extensions takes a source pattern and if it matches it will transform the output
# to your desired path. The default behaviour is to the .mocks.dart file to be in the same
# directory as the source .dart file. As seen below this is customizable, but the generated
# file must always end in `.mocks.dart`.
build_extensions:
'^tests/{{}}.dart' : 'tests/mocks/{{}}.mocks.dart'
'^integration-tests/{{}}.dart' : 'integration-tests/{{}}.mocks.dart'
```

Also, you can also check out the example configuration in the Mockito repository.
7 changes: 7 additions & 0 deletions build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ targets:
generate_for:
- example/**.dart
- test/end2end/*.dart
options:
# build_extensions takes a source pattern and if it matches it will transform the output
# to your desired path. The default behaviour is to the .mocks.dart file to be in the same
# directory as the source .dart file. As seen below this is customizable, but the generated
# file must always end in `.mocks.dart`.
build_extensions:
'^example/build_extensions/{{}}.dart' : 'example/build_extensions/mocks/{{}}.mocks.dart'

builders:
mockBuilder:
Expand Down
40 changes: 40 additions & 0 deletions example/build_extensions/example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright 2023 Dart Mockito authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test_api/scaffolding.dart';

// Because we customized the `build_extensions` option, we can output
// the generated mocks in a diferent directory
import 'mocks/example.mocks.dart';

class Dog {
String sound() => "bark";
bool? eatFood(String? food) => true;
Future<void> chew() async => print('Chewing...');
int? walk(List<String>? places) => 1;
}

@GenerateNiceMocks([MockSpec<Dog>()])
void main() {
test("Verify some dog behaviour", () async {
MockDog mockDog = MockDog();
when(mockDog.eatFood(any));

mockDog.eatFood("biscuits");

verify(mockDog.eatFood(any)).called(1);
});
}
57 changes: 48 additions & 9 deletions lib/src/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,41 @@ import 'package:source_gen/source_gen.dart';
/// will produce a "'.mocks.dart' file with such mocks. In this example,
/// 'foo.mocks.dart' will be created.
class MockBuilder implements Builder {
@override
final Map<String, List<String>> buildExtensions = {
'.dart': ['.mocks.dart']
};

MockBuilder({Map<String, List<String>>? buildExtensions}) {
this.buildExtensions.addAll(buildExtensions ?? {});
}

@override
Future<void> build(BuildStep buildStep) async {
if (!await buildStep.resolver.isLibrary(buildStep.inputId)) return;
final entryLib = await buildStep.inputLibrary;
final sourceLibIsNonNullable = entryLib.isNonNullableByDefault;
final mockLibraryAsset = buildStep.inputId.changeExtension('.mocks.dart');

// While it can be acceptable that we get more than 2 allowedOutputs,
// because it's the general one and the user defined one. Having
// more, means that user has conflicting patterns so we should throw.
if (buildStep.allowedOutputs.length > 2) {
throw ArgumentError('Build_extensions has conflicting outputs on file '
'`${buildStep.inputId.path}`, it usually caused by missconfiguration '
'on your `build.yaml` file');
}
// if not single, we always choose the user defined one.
final mockLibraryAsset = buildStep.allowedOutputs.singleOrNull ??
buildStep.allowedOutputs
.where((element) =>
element != buildStep.inputId.changeExtension('.mocks.dart'))
.single;
final inheritanceManager = InheritanceManager3();
final mockTargetGatherer =
_MockTargetGatherer(entryLib, inheritanceManager);

final entryAssetId = await buildStep.resolver.assetIdForElement(entryLib);
final assetUris = await _resolveAssetUris(buildStep.resolver,
mockTargetGatherer._mockTargets, entryAssetId.path, entryLib);
mockTargetGatherer._mockTargets, mockLibraryAsset.path, entryLib);

final mockLibraryInfo = _MockLibraryInfo(mockTargetGatherer._mockTargets,
assetUris: assetUris,
Expand Down Expand Up @@ -240,11 +262,6 @@ $rawOutput
}
return element.library!;
}

@override
final buildExtensions = const {
'.dart': ['.mocks.dart']
};
}

/// An [Element] visitor which collects the elements of all of the
Expand Down Expand Up @@ -2304,7 +2321,29 @@ class _AvoidConflictsAllocator implements Allocator {
}

/// A [MockBuilder] instance for use by `build.yaml`.
Builder buildMocks(BuilderOptions options) => MockBuilder();
Builder buildMocks(BuilderOptions options) {
final buildExtensions = options.config['build_extensions'];
if (buildExtensions == null) return MockBuilder();
if (buildExtensions is! Map) {
throw ArgumentError(
'build_extensions should be a map from inputs to outputs');
}
final result = <String, List<String>>{};
for (final entry in buildExtensions.entries) {
final input = entry.key;
final output = entry.value;
if (input is! String || !input.endsWith('.dart')) {
throw ArgumentError('Invalid key in build_extensions `$input`, it '
'should be a string ending with `.dart`');
}
if (output is! String || !output.endsWith('.mocks.dart')) {
throw ArgumentError('Invalid key in build_extensions `$output`, it '
'should be a string ending with `mocks.dart`');
}
result[input] = [output];
}
return MockBuilder(buildExtensions: result);
}

extension on Element {
/// Returns the "full name" of a class or method element.
Expand Down
120 changes: 114 additions & 6 deletions test/builder/auto_mocks_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import 'package:mockito/src/builder.dart';
import 'package:package_config/package_config.dart';
import 'package:test/test.dart';

Builder buildMocks(BuilderOptions options) => MockBuilder();

const annotationsAsset = {
'mockito|lib/annotations.dart': '''
class GenerateMocks {
Expand Down Expand Up @@ -86,25 +84,27 @@ void main() {

/// Test [MockBuilder] in a package which has not opted into null safety.
Future<void> testPreNonNullable(Map<String, String> sourceAssets,
{Map<String, /*String|Matcher<String>*/ Object>? outputs}) async {
{Map<String, /*String|Matcher<String>*/ Object>? outputs,
Map<String, dynamic> config = const <String, dynamic>{}}) async {
final packageConfig = PackageConfig([
Package('foo', Uri.file('/foo/'),
packageUriRoot: Uri.file('/foo/lib/'),
languageVersion: LanguageVersion(2, 7))
]);
await testBuilder(buildMocks(BuilderOptions({})), sourceAssets,
await testBuilder(buildMocks(BuilderOptions(config)), sourceAssets,
writer: writer, outputs: outputs, packageConfig: packageConfig);
}

/// Test [MockBuilder] in a package which has opted into null safety.
Future<void> testWithNonNullable(Map<String, String> sourceAssets,
{Map<String, /*String|Matcher<List<int>>*/ Object>? outputs}) async {
{Map<String, /*String|Matcher<List<int>>*/ Object>? outputs,
Map<String, dynamic> config = const <String, dynamic>{}}) async {
final packageConfig = PackageConfig([
Package('foo', Uri.file('/foo/'),
packageUriRoot: Uri.file('/foo/lib/'),
languageVersion: LanguageVersion(3, 0))
]);
await testBuilder(buildMocks(BuilderOptions({})), sourceAssets,
await testBuilder(buildMocks(BuilderOptions(config)), sourceAssets,
writer: writer, outputs: outputs, packageConfig: packageConfig);
}

Expand Down Expand Up @@ -3662,6 +3662,114 @@ void main() {
contains('bar: _FakeBar_0('))));
});
});

group('build_extensions support', () {
test('should export mocks to different directory', () async {
await testWithNonNullable({
...annotationsAsset,
...simpleTestAsset,
'foo|lib/foo.dart': '''
import 'bar.dart';
class Foo extends Bar {}
''',
'foo|lib/bar.dart': '''
import 'dart:async';
class Bar {
m(Future<void> a) {}
}
''',
}, config: {
"build_extensions": {"^test/{{}}.dart": "test/mocks/{{}}.mocks.dart"}
});
final mocksAsset = AssetId('foo', 'test/mocks/foo_test.mocks.dart');
final mocksContent = utf8.decode(writer.assets[mocksAsset]!);
expect(mocksContent, contains("import 'dart:async' as _i3;"));
expect(mocksContent, contains('m(_i3.Future<void>? a)'));
});

test('should throw if it has confilicting outputs', () async {
await expectLater(
testWithNonNullable({
...annotationsAsset,
...simpleTestAsset,
'foo|lib/foo.dart': '''
import 'bar.dart';
class Foo extends Bar {}
''',
'foo|lib/bar.dart': '''
import 'dart:async';
class Bar {
m(Future<void> a) {}
}
''',
}, config: {
"build_extensions": {
"^test/{{}}.dart": "test/mocks/{{}}.mocks.dart",
"test/{{}}.dart": "test/{{}}.something.mocks.dart"
}
}),
throwsArgumentError);
final mocksAsset = AssetId('foo', 'test/mocks/foo_test.mocks.dart');
final otherMocksAsset = AssetId('foo', 'test/mocks/foo_test.mocks.dart');
final somethingMocksAsset =
AssetId('foo', 'test/mocks/foo_test.something.mocks.dart');

expect(writer.assets.containsKey(mocksAsset), false);
expect(writer.assets.containsKey(otherMocksAsset), false);
expect(writer.assets.containsKey(somethingMocksAsset), false);
});

test('should throw if input is in incorrect format', () async {
await expectLater(
testWithNonNullable({
...annotationsAsset,
...simpleTestAsset,
'foo|lib/foo.dart': '''
import 'bar.dart';
class Foo extends Bar {}
''',
'foo|lib/bar.dart': '''
import 'dart:async';
class Bar {
m(Future<void> a) {}
}
''',
}, config: {
"build_extensions": {"^test/{{}}": "test/mocks/{{}}.mocks.dart"}
}),
throwsArgumentError);
final mocksAsset = AssetId('foo', 'test/mocks/foo_test.mocks.dart');
final mocksAssetOriginal = AssetId('foo', 'test/foo_test.mocks.dart');

expect(writer.assets.containsKey(mocksAsset), false);
expect(writer.assets.containsKey(mocksAssetOriginal), false);
});

test('should throw if output is in incorrect format', () async {
await expectLater(
testWithNonNullable({
...annotationsAsset,
...simpleTestAsset,
'foo|lib/foo.dart': '''
import 'bar.dart';
class Foo extends Bar {}
''',
'foo|lib/bar.dart': '''
import 'dart:async';
class Bar {
m(Future<void> a) {}
}
''',
}, config: {
"build_extensions": {"^test/{{}}.dart": "test/mocks/{{}}.g.dart"}
}),
throwsArgumentError);
final mocksAsset = AssetId('foo', 'test/mocks/foo_test.mocks.dart');
final mocksAssetOriginal = AssetId('foo', 'test/foo_test.mocks.dart');
expect(writer.assets.containsKey(mocksAsset), false);
expect(writer.assets.containsKey(mocksAssetOriginal), false);
});
});
}

TypeMatcher<List<int>> _containsAllOf(a, [b]) => decodedMatches(
Expand Down

0 comments on commit 4ff995f

Please sign in to comment.