runInTestTransaction function

Future<void> runInTestTransaction(
  1. BeanFactory beans,
  2. FutureOr<void> body(), {
  3. bool rollback = true,
  4. String? transactionManager,
  5. List<FutureOr<void> Function()> before = const [],
  6. List<FutureOr<void> Function()> after = const [],
})

Runs a test in a transaction of every PlatformTransactionManager its repositories write through, and rolls them back afterwards (or commits them, with rollback: false): what a @DataTest class does around each test.

The managers are those of the application (the primary one and the ones of @UseDataSource names) and those the repository factory made for the stores of the repositories: the in-memory store, the primary database and each named one. A manager binds its transaction to its store or database, so all the transactions are open at the same time, side by side in one scope; a repository on any of them joins its own.

The transactions begin first, then run, in this order, the before steps (the @BeforeEach methods), body (the test) and the after steps (the @AfterEach methods), all inside the scope: the repositories and the @Transactional methods they call join the transactions, so nothing a test writes outlives it. A step that fails stops the ones after it of its kind: when a before step fails, body is skipped, and the after steps run anyway (as tearDown does after a failing setUp).

The first error is thrown once everything has run; later ones are logged. A test that failed is rolled back even with rollback: false, so a failure leaves nothing behind for the tests after it. A transaction that was marked rollback-only by a participant fails a committing test with an UnexpectedRollbackException, as in Spring; the transactions of the other managers are rolled back then, as they are when committing one fails.

final app = springBootTest(springApplication);

test('saves a book', () => runInTestTransaction(app.context, () async {
  await app.getBean<BookRepository>().save(Book(title: 'Dune'));
})); // rolled back

The steps run in the scope's zone, so a package:test setUp or tearDown cannot be part of it: the generated code calls the lifecycle methods from here instead.

With transactionManager only the manager of that name (the bean's name or qualifier, as in @Transactional(transactionManager: ...)) gets a transaction, the way Spring's test uses the one manager it names; a repository on another store then writes outside the test's transaction and keeps what it writes. An unknown name throws a StateError.

Spring (Java): TransactionalTestExecutionListener, which starts the transaction before @BeforeEach and ends it after @AfterEach, with the manager of @Transactional(transactionManager:) or the only one.

Implementation

Future<void> runInTestTransaction(
  BeanFactory beans,
  FutureOr<void> Function() body, {
  bool rollback = true,
  String? transactionManager,
  List<FutureOr<void> Function()> before = const [],
  List<FutureOr<void> Function()> after = const [],
}) {
  final managers = transactionManager == null || transactionManager.isEmpty
      ? _managersOf(beans)
      : [_namedManager(beans, transactionManager)];
  return TransactionSynchronizationManager.runInScope<void>(() async {
    final begun = <(PlatformTransactionManager, TransactionStatus)>[];
    Object? failure;
    StackTrace? trace;
    void failed(Object error, StackTrace stackTrace) {
      if (failure == null) {
        failure = error;
        trace = stackTrace;
      } else {
        _log.warning(
          'Another failure in the test after the first one',
          error: error,
          stackTrace: stackTrace,
        );
      }
    }

    // Ends the transactions that are left, the last begun first.
    Future<void> end({required bool rollback}) async {
      var commit = !rollback;
      while (begun.isNotEmpty) {
        final (manager, status) = begun.removeLast();
        try {
          if (commit) {
            await manager.commit(status);
          } else {
            await manager.rollback(status);
          }
        } on Object catch (error, stackTrace) {
          failed(error, stackTrace);
          // What was not committed must not be left open or half done.
          commit = false;
        }
      }
    }

    try {
      for (final manager in managers) {
        begun.add((manager, await manager.getTransaction(_definition)));
      }
    } on Object catch (error, stackTrace) {
      failed(error, stackTrace);
      await end(rollback: true);
      Error.throwWithStackTrace(failure!, trace!);
    }
    var prepared = true;
    for (final step in before) {
      try {
        await step();
      } on Object catch (error, stackTrace) {
        failed(error, stackTrace);
        prepared = false;
        break;
      }
    }
    if (prepared) {
      try {
        await body();
      } on Object catch (error, stackTrace) {
        failed(error, stackTrace);
      }
    }
    for (final step in after) {
      try {
        await step();
      } on Object catch (error, stackTrace) {
        failed(error, stackTrace);
      }
    }
    await end(rollback: rollback || failure != null);
    if (failure case final error?) {
      Error.throwWithStackTrace(error, trace!);
    }
  });
}