LogoDart Code Generation

Testing Dart Generators#

Source of truth: build_test (v3.5.12) and source_gen_test (v1.3.x), used by all three generator packages.


Overview#

Testing generators requires:

  1. build_test — Core package for testing any Builder implementation
  2. source_gen_test — Higher-level helpers for testing Generator implementations
# 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