Skip to main content

Test a module

smf_pipeline has a library for the tests of modules, package:smf_pipeline/testing.dart, with two tools. The contract harness generates every app that matters for a module in memory and checks it against the rules of the module model. ModulePackage checks what the package of a module imports and depends on. The built-in modules test themselves with both, and yours should too.

The contract harness​

test/counter_module_test.dart
import 'package:smf_bloc/smf_bloc.dart';
import 'package:smf_counter/smf_counter.dart';
import 'package:smf_flutter_core/smf_flutter_core.dart';
import 'package:smf_get_it/smf_get_it.dart';
import 'package:smf_go_router/smf_go_router.dart';
import 'package:smf_pipeline/smf_pipeline.dart';
import 'package:smf_pipeline/testing.dart';
import 'package:smf_riverpod/smf_riverpod.dart';
import 'package:test/test.dart';

void main() {
test('every app of the module follows the rules of its roles', () async {
final harness = ContractHarness(
ModuleRegistry(const [
FlutterCoreModule(),
GoRouterModule(),
GetItModule(),
BlocModule(),
RiverpodModule(),
CounterModule(),
]),
);
final results = await harness.checkAll();
for (final result in results) {
expect(result.errors, isEmpty, reason: '${result.contractCase}');
}
});
}

checkAll() builds a case for every module and every role of the registry. For a module, that is an app with each provider of the role of its variants, with each provider of every role it requires that has several, and with each subset of the roles it only uses. For the counter above, the cases include counter (bloc) and counter (riverpod).

It also builds a case of the module with each provider in the registry that brings a package which the module adds, itself or in a variant, when the two can be in one app (see the packages of a provider). The case is named <module> with <provider>, such as banner with bloc for a module without variants that adds flutter_bloc. When the provider provides another role than that of the module's variants, the case is <module> (<provider of the variant>) with <provider>, one for each variant that adds the package, or for the first variant when only the module itself adds it. A case whose app another case builds already is left out.

For each case, the harness runs the stages of the pipeline up to rendering, the way a run without a terminal that skips external setup would. It answers the questions of the roles like a user who presses Enter, and renders the app in memory. Then it checks, among other things:

  • the rules of the module's kind, the access rules of sockets and roles, and the roles in when;
  • the module rules of every role, and its structural rules over the parsed Dart files of the app;
  • that every provider generates the symbols its role requires;
  • the tags of the sockets in the templates, and templates that mustache would copy with their braces;
  • that every import finds its file and every imported package is a dependency of the app;
  • that a module imports only the files it may use: its own, those of the modules it depends on directly and those of its roles, and, for the template and the providers of a role, the files of the modules that give the role data, which they render, such as the screens that a router routes. The imports of a template count for the module of the template, and an import that the pipeline adds for a fragment counts for the module of the fragment, as the errors say with "in the template of …" and "for a fragment of …";
  • that a module which imports or exports the package of a provider of a role in the app, in a template or for a fragment, adds that package itself, even when it depends on the provider. A provider of a role may import, for a fragment, the package that a module which gives the role data adds, as the data may need it.

What the registry holds decides what the harness can check, so put these modules into it:

  • FlutterCoreModule, the app entry that every app needs;
  • every module that your module depends on, which the registry requires anyway;
  • a provider of every role your module requires or uses;
  • every provider that your module has a variant for, which the registry requires as well;
  • the provider of every package that your module adds and that a provider of a role brings, such as RiverpodModule for a module that adds flutter_riverpod.

SMF allows the package of a provider only in a variant for that provider or with a dependency on it, and smf create checks this only in apps that have the provider. With the provider in the registry, checkAll() builds such an app of your module, so your tests find the mistake rather than a user of your module.

Checking one app​

harness.check(case) builds one app and returns its result, whose app holds the rendered files:

final result = await ContractHarness(registry).check(
const ContractCase('counter', requested: [CounterModule.id, BlocModule.id]),
);
final screen = result.app!.files['lib/features/counter/counter_screen.dart']!;

ContractCase takes the modules to request, the provider to pick for a role with several, and the values of role options, such as {'start': '/counter'}. Check the rendered code as code rather than as text, by parsing it with the analyzer and looking for the declarations and calls you expect.

A module whose tests need a main navigation, but which is not a feature itself, can declare small features and a layout of its own in test/, as smf_firebase_analytics does in test/support/navigation.dart.

The rules of a module package​

test/architecture_test.dart
import 'package:smf_pipeline/testing.dart';
import 'package:test/test.dart';

void main() {
test('the package follows the rules of a module package', () {
expect(
const ModulePackage(
'smf_counter',
dependencies: {'mason'},
testModules: {
'smf_bloc',
'smf_flutter_core',
'smf_get_it',
'smf_go_router',
'smf_riverpod',
},
).problems(),
isEmpty,
);
});
}

problems() reads the package in the current directory, which is the package's own when dart test runs, and reports every package and import that breaks a rule:

  • The code in lib/ imports only the module model of smf_contracts, its own files, the public libraries of its dependencies, and libraries of Dart that do not reach the machine, which rules out dart:io.
  • Of the SMF packages, the tests use only the module model, the package itself, its dependencies, smf_pipeline and the testModules.
  • The package depends on smf_contracts and its dependencies and on nothing else. Its dev dependencies among the SMF packages are smf_pipeline and the testModules.

pub.dev resolves the dev dependencies of a package when it analyzes it, so the dev dependencies between module packages must not form a cycle. When two modules would need each other in their tests, one of them tests with a small provider of its own in test/.

Beyond the harness​

The harness renders apps but does not build them. To see your module in a real app, generate apps with your command, as Extending SMF shows, with each state manager and each provider your module works with, and run flutter analyze on them. SMF's CI does this for the built-in modules.

A module whose generated code is plain Dart can go further in its tests. It can write the rendered files to a temporary directory with the packages of the tests, analyze them and run them in the Dart VM. The tests of smf_get_it and smf_event_bus run the generated services with the real get_it and event_bus this way.