Heimdall Test

Development status: Heimdall Test is still under active development. The public API may change between minor versions, and breaking changes can happen while the package is being shaped.

Heimdall Test is a Dart architecture testing package inspired by ArchUnit. It imports Dart source trees with the analyzer, keeps the real AST nodes, and lets tests describe executable rules about files, type declarations, members, dependencies, layers, and feature slices.

Features

  • Import Dart projects or subtrees with HeimdallFileImporter.
  • Filter imported files with options such as ExcludeTestsImportOption, IncludeLibraryImportOption, and ExcludeGeneratedDartImportOption.
  • Run fluent rules for classes, files, fields, methods, constructors, and code units through Heimdall.
  • Check naming, paths, annotations, modifiers, parse errors, imports, source snippets, public class/file conventions, and basic member usage.
  • Query dependencies between declarations using local imports, exports, parts, and barrel files.
  • Model layer rules with allowed access constraints.
  • Model slices by path capture and detect cycles between them.
  • Freeze existing violations so only new findings fail a test.
  • Bundle reusable checks with plugins and run them through HeimdallRunner.

Quick Start

import 'package:heimdall_test/heimdall_test.dart';

void main() {
  final project = const HeimdallFileImporter().importPath();

  Heimdall.classes()
      .that()
      .resideInPath('src/data')
      .should()
      .haveNameEndingWith('Repository')
      .check(project)
      .assertNoFindings();
}

Importing Source

HeimdallFileImporter recursively reads .dart files, parses them with the analyzer, and returns a HeimdallProject.

final project = const HeimdallFileImporter(
  importOptions: [ExcludeTestsImportOption()],
).importPath('.');

The imported model exposes:

  • HeimdallProject.files
  • HeimdallProject.declarations
  • HeimdallProject.typeDeclarations
  • HeimdallProject.dependencies
  • HeimdallSourceFile.parseErrors
  • analyzer nodes enriched by Heimdall extensions such as name, line, relativePath, targetUri, and targetFile

Fluent Rules

Class and type declaration rules:

Heimdall.classes()
    .that()
    .arePublic()
    .and()
    .resideInPath('lib/src/domain')
    .should()
    .beFinal()
    .check(project)
    .assertNoFindings();

File rules:

Heimdall.files()
    .that()
    .resideInPath('lib/src')
    .should()
    .notImportUri('dart:mirrors')
    .check(project)
    .assertNoFindings();

Member rules:

Heimdall.fields()
    .that()
    .arePublic()
    .should()
    .beFinal()
    .check(project)
    .assertNoFindings();

Negating predicates and conditions

Builder .not() methods negate only the next DSL call in the fluent chain. Use them when writing inline rules.

In a predicate chain, .not() changes which items are selected:

Heimdall.classes()
    .that()
    .resideInPath('lib/src/data')
    .and()
    .not()
    .haveTypeNameEndingWith('RepositoryImpl')
    .should()
    .haveTypeNameEndingWith('Repository')
    .check(project);

In a condition chain, .not() changes the next assertion:

Heimdall.files()
    .that()
    .resideInPath('lib/src')
    .should()
    .not()
    .importUri('dart:mirrors')
    .check(project);

HeimdallPredicate.not() and HeimdallCondition.not() are lower-level composition APIs. They return a new negated predicate or condition object, which is useful when extracting reusable custom checks before passing them to .satisfy(...).

final notGenerated = HeimdallPredicate<HeimdallSourceFile>(
  'be generated',
  (file, _) => file.relativePath.endsWith('.g.dart'),
).not();

Heimdall.files().that().satisfy(notGenerated).should().haveNoParseErrors();

Built-In Sights

Heimdall.code() provides source hygiene rules:

Heimdall.code().shouldParse().check(project).assertNoFindings();
Heimdall.code().shouldNotImportDartMirrors().check(project).assertNoFindings();
Heimdall.code().publicSignaturesShouldNotUseDynamic().check(project).assertNoFindings();

Heimdall.dependencies() provides dependency rules:

Heimdall.dependencies()
    .noClassesShouldDependOnUpperDirectories()
    .check(project)
    .assertNoFindings();

Layers

Use layers when package areas may only depend on specific other areas.

final rule = Heimdall.layers()
    .layer('Domain').definedBy(['lib/src/domain'])
    .layer('Application').definedBy(['lib/src/application'])
    .layer('Infrastructure').definedBy(['lib/src/data'])
    .whereLayer('Domain').mayNotAccessAnyLayer()
    .whereLayer('Application').mayOnlyAccessLayers(['Domain'])
    .whereLayer('Infrastructure').mayOnlyAccessLayers(['Application', 'Domain'])
    .asRule(description: 'onion architecture');

rule.check(project).assertNoFindings();

Slices

Use slices for repeated architecture areas, such as features.

Heimdall.slices('lib/src/features/(*)')
    .shouldBeFreeOfCycles()
    .check(project)
    .assertNoFindings();

(*) captures the slice name from the path. Heimdall can report cycles or any cross-slice dependency.

Freezing Known Violations

Freezing records the current findings in a JSON file and reports only new findings on later runs.

Heimdall.classes()
    .should()
    .beFinal()
    .freeze(storePath: '.heimdall/final_classes.json')
    .check(project)
    .assertNoFindings();

Plugins

Plugins group reusable rules.

final class ArchitecturePlugin implements HeimdallRulePlugin {
  @override
  String get name => 'architecture';

  @override
  Iterable<PluginRule> rules() => [
        PluginRule(
          'code parses',
          (project) => Heimdall.code().shouldParse().check(project),
        ),
      ];
}

final reports = HeimdallRunner([ArchitecturePlugin()]).run(project);
for (final report in reports) {
  report.assertNoFindings();
}

Path Patterns

Path-based APIs use pathMatches semantics:

  • Plain text matches a path segment, for example data.
  • A plain path like lib/src/data matches only that exact path.
  • A path fragment like data/repository matches that sequence of segments anywhere.
  • Use lib/src/data/** to match every item under lib/src/data.
  • Use **data/repository/** or **/data/repository/** to match a nested path anywhere in the source tree.
  • ..service.. matches a path segment named service; ..data/service.. matches that sequence of path segments anywhere.
  • .. is a path-fragment wildcard. It can cross / separators and is useful for "contains this path".
  • * is a glob wildcard inside one path segment. It does not cross /.
  • ** matches across path segments.
  • A glob pattern starting with /, such as /data/**/*impl.dart, matches that segment sequence at any depth.
  • (*) captures one path segment for slice and layer helpers.

Examples:

pathMatches('lib/src/data', 'lib/src/data'); // true
pathMatches('lib/src/data/user_repository.dart', 'lib/src/data'); // false
pathMatches('lib/src/features/user/data/repository/user_repository.dart', 'data/repository'); // true
pathMatches('lib/src/data/user_repository.dart', 'lib/src/data/**'); // true
pathMatches('lib/src/features/user/data/repository/user_repository.dart', '**data/repository/**'); // true
pathMatches('lib/src/features/user/data/service/user_service.dart', '..data/service..'); // true
pathMatches('lib/src/features/user/data/service/user_impl.dart', '/data/**/*impl.dart'); // true

Reports

Every executable rule returns a HeimdallReport with a description, checked item count, and HeimdallFinding list. In tests, call assertNoFindings() to turn rule findings into a failing assertion.

Libraries

heimdall_test
Heimdall sight rules for Dart source trees.