Testing Dart Generators#
Source of truth:
build_test(v3.5.12) andsource_gen_test(v1.3.x), used by all three generator packages.
Overview#
Testing generators requires:
-
build_test— Core package for testing anyBuilderimplementation -
source_gen_test— Higher-level helpers for testingGeneratorimplementations
# pubspec.yaml dev_dependencies
dev_dependencies:
build_test: ^3.3.0
source_gen_test: ^1.3.0
test: ^1.25.0
build_runner: ^2.10.0
build_test: testBuilder#
The primary testing function. Runs a builder against in-memory source files and checks outputs.
import 'package:build_test/build_test.dart';
import 'package:build/build.dart';
import 'package:test/test.dart';
void main() {
test('generates correct output', () async {
await testBuilder(
// The builder to test
myBuilder(BuilderOptions.empty),
// Input assets: map of 'package|path' → content
{
'my_pkg|lib/model.dart': '''
import 'package:my_annotations/my_annotations.dart';
part 'model.g.dart';
@MyAnnotation()
class Model {
final String name;
Model({required this.name});
}
''',
},
// Expected outputs (optional: if not provided, just checks it doesn't throw)
outputs: {
// For SharedPartBuilder → .g.part files in cache
// For combined output → the .g.dart
'my_pkg|lib/model.my_builder.g.part': decodedMatches(
allOf([
contains('class ModelMapper'),
contains('String name'),
]),
),
},
// Package name (usually 'test' or the package being tested)
// Defaults to 'a' if not specified
rootPackage: 'my_pkg',
// Optional: reader to provide additional assets (like pubspec.yaml)
// reader: PackageAssetReader.currentIsolate(),
// Optional: suppress log output in tests
// onLog: (_) {},
);
});
}
testBuilder signature#
Future<void> testBuilder(
Builder builder,
Map<String, Object> sourceAssets, {
Map<String, Object>? outputs,
Set<String>? generateFor, // Glob patterns to limit which inputs trigger
RecordingAssetWriter? writer, // Capture outputs for inspection
PackageAssetReader? reader, // Provide additional readable assets
String? rootPackage, // Which package is "root"
void Function(LogRecord)? onLog, // Handle log messages
});
Input asset keys#
Format: 'package_name|path/to/file.dart'
{
'my_pkg|lib/model.dart': '...', // lib source
'my_pkg|test/model_test.dart': '...', // test source
'my_pkg|pubspec.yaml': '''
name: my_pkg
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
''',
}
Output matchers#
outputs: {
// Exact string match
'my_pkg|lib/model.g.dart': 'class ModelMapper { }',
// Matcher-based
'my_pkg|lib/model.g.dart': decodedMatches(
contains('class ModelMapper'), // contains substring
),
'my_pkg|lib/model.g.dart': decodedMatches(
allOf([
contains('fromJson'),
contains('toJson'),
isNot(contains('TODO')),
]),
),
'my_pkg|lib/model.g.dart': decodedMatches(
matches(RegExp(r'class \w+Mapper')), // regex
),
}
Testing with Different BuilderOptions#
test('respects snake_case option', () async {
await testBuilder(
myBuilder(BuilderOptions(const {
'field_rename': 'snake',
'include_null': false,
})),
{
'pkg|lib/model.dart': '''
@MyAnnotation()
class Model { final String firstName; }
''',
},
outputs: {
'pkg|lib/model.my_builder.g.part': decodedMatches(
contains("'first_name'"),
),
},
);
});
Testing Error Cases#
Generators should throw InvalidGenerationSourceError for invalid usage:
test('throws on non-class element', () async {
expect(
() => testBuilder(
myBuilder(BuilderOptions.empty),
{
'pkg|lib/bad.dart': '''
@MyAnnotation()
enum BadTarget { a, b }
''',
},
),
throwsA(isA<InvalidGenerationSourceError>()),
);
});
// Or check the error message
test('throws with helpful message', () async {
await expectLater(
testBuilder(
myBuilder(BuilderOptions.empty),
{'pkg|lib/bad.dart': '@MyAnnotation()\nenum Bad { a }'},
),
throwsA(
isA<InvalidGenerationSourceError>().having(
(e) => e.message,
'message',
contains('classes'),
),
),
);
});
Testing Logging#
test('logs a warning for abstract classes', () async {
final logs = <LogRecord>[];
await testBuilder(
myBuilder(BuilderOptions.empty),
{'pkg|lib/model.dart': '@MyAnnotation()\nabstract class Model {}'},
onLog: logs.add,
);
expect(
logs,
contains(
isA<LogRecord>()
.having((r) => r.level, 'level', Level.WARNING)
.having((r) => r.message, 'message', contains('abstract')),
),
);
});
Testing No Output#
When a file doesn't have any annotated elements, the builder should produce no output:
test('produces no output for unannotated classes', () async {
await testBuilder(
myBuilder(BuilderOptions.empty),
{
'pkg|lib/plain.dart': '''
class PlainClass {
final String name;
PlainClass({required this.name});
}
''',
},
// No 'outputs' key means we expect no output files
);
});
Using PackageAssetReader for External Deps#
When your generator reads annotation classes from another package:
import 'package:build_test/build_test.dart';
test('reads annotation from dependency', () async {
// Use the current isolate's packages (reads from your pub cache)
final reader = await PackageAssetReader.currentIsolate();
await testBuilder(
myBuilder(BuilderOptions.empty),
{
'pkg|lib/model.dart': '''
import 'package:my_annotations/my_annotations.dart';
part 'model.g.dart';
@MyAnnotation()
class Model { final String id; }
''',
},
reader: reader,
outputs: {
'pkg|lib/model.my_builder.g.part': decodedMatches(contains('fromJson')),
},
);
});
source_gen_test: Golden File Tests#
source_gen_test provides a pattern for comparing against "golden" expected files. Used heavily by json_serializable.
Directory structure#
test/
my_test_case/
input.dart ← Source with @MyAnnotation
expected_output.dart ← Expected generated code
another_test_case/
input.dart
expected_output.dart
Test code#
import 'package:source_gen_test/source_gen_test.dart';
import 'package:build_test/build_test.dart';
void main() {
initializeBuildLogTesting();
group('MyGenerator', () {
final reader = SourceGenTestReader();
setUpAll(() async {
await reader.setUp(
[
TestInput.fromPath('test/my_test_case/input.dart'),
],
);
});
test('generates expected output', () {
reader.expectGeneratedPart(
'test/my_test_case/input.dart',
'test/my_test_case/expected_output.dart',
);
});
});
}
Testing the Combined Output#
When using SharedPartBuilder + combining_builder, the output is in a .g.dart file. Test against the combined file:
// json_serializable pattern: test the combined .g.dart directly
test('combined output is correct', () async {
final writer = RecordingAssetWriter();
await testBuilder(
myBuilder(BuilderOptions.empty),
{
'pkg|lib/model.dart': '''
import 'package:my_annotations/my_annotations.dart';
part 'model.g.dart';
@MyAnnotation()
class Model { final String name; }
''',
},
writer: writer,
);
final outputId = AssetId('pkg', 'lib/model.my_builder.g.part');
final content = writer.assets[outputId];
expect(content, isNotNull);
expect(utf8.decode(content!), contains('class ModelMapper'));
});
Integration Test Pattern#
For testing the complete build pipeline (including combining):
// Create a temp directory with actual package files
// Then run build_runner programmatically
// (Only needed for end-to-end tests — prefer unit tests with testBuilder)
import 'package:test_process/test_process.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
test('end-to-end build succeeds', () async {
await d.dir('my_project', [
d.file('pubspec.yaml', '''
name: my_project
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
my_annotations:
path: ../my_annotations
dev_dependencies:
my_generator:
path: ../my_generator
build_runner: any
'''),
d.dir('lib', [
d.file('model.dart', '''
import 'package:my_annotations/my_annotations.dart';
part 'model.g.dart';
@MyAnnotation()
class Model { final String name; }
'''),
]),
]).create();
final process = await TestProcess.start(
'dart',
['run', 'build_runner', 'build', '--delete-conflicting-outputs'],
workingDirectory: d.path('my_project'),
);
await process.shouldExit(0);
await d.file('my_project/lib/model.g.dart', contains('class ModelMapper')).validate();
});
Common Test Patterns#
Test matrix: multiple annotations#
for (final (input, expected) in [
('@MyAnnotation()', 'class ModelMapper'),
('@MyAnnotation(name: "custom")', "name = 'custom'"),
('@MyAnnotation(rename: FieldRename.snake)', "'first_name'"),
]) {
test('input: $input', () async {
await testBuilder(
myBuilder(BuilderOptions.empty),
{
'pkg|lib/model.dart': '''
@${input}
class Model { final String firstName; }
''',
},
outputs: {
'pkg|lib/model.my_builder.g.part': decodedMatches(contains(expected)),
},
);
});
}
Test with shared setup#
group('with default options', () {
late Map<String, String> baseInputs;
setUp(() {
baseInputs = {
'pkg|lib/model.dart': '''
import 'package:my_annotations/my_annotations.dart';
part 'model.g.dart';
@MyAnnotation()
class Model {
final String id;
final String name;
Model({required this.id, required this.name});
}
''',
};
});
test('generates fromJson', () async {
await testBuilder(
myBuilder(BuilderOptions.empty),
baseInputs,
outputs: {
'pkg|lib/model.my_builder.g.part': decodedMatches(
contains('_\$ModelFromJson'),
),
},
);
});
test('generates toJson', () async {
await testBuilder(
myBuilder(BuilderOptions.empty),
baseInputs,
outputs: {
'pkg|lib/model.my_builder.g.part': decodedMatches(
contains('_\$ModelToJson'),
),
},
);
});
});
Key Imports for Testing#
import 'package:build_test/build_test.dart';
// Provides: testBuilder, RecordingAssetWriter, PackageAssetReader,
// decodedMatches, resolveSources
import 'package:source_gen_test/source_gen_test.dart';
// Provides: initializeBuildLogTesting, expectGeneratedPart
import 'package:build/build.dart';
// Provides: BuilderOptions
import 'package:test/test.dart';
// Standard test framework
import 'dart:convert';
// For utf8.decode when inspecting raw bytes