Testing Dart code

Structure suites with package:test, assert with matchers, control time with fake_async, and stub collaborators cleanly.

Suites, groups and matchers

import 'package:test/test.dart';
import 'package:shop_core/shop_core.dart';

void main() {
  group('Money', () {
    late Money subject;

    setUp(() => subject = Money.cents(1000, 'EUR'));
    tearDown(() => subject = Money.cents(0, 'EUR'));

    test('adds in the same currency', () {
      expect(subject + Money.cents(250, 'EUR'), Money.cents(1250, 'EUR'));
    });

    test('rejects a currency mismatch', () {
      expect(
        () => subject + Money.cents(1, 'USD'),
        throwsA(isA<ArgumentError>()),
      );
    });

    test('matchers cover the common shapes', () {
      expect(subject.cents, equals(1000));
      expect(subject.cents, greaterThan(999));
      expect(subject.currency, isIn(['EUR', 'USD']));
      expect(() => Money.cents(-1, 'EUR'), throwsArgumentError);
      expect(subject.toString(), contains('EUR'));
      expect([1, 2, 3], contains(2));
      expect(subject, isNot(same(Money.cents(1000, 'EUR'))));  // identity, not equality
    });

    test('handles completion and emptiness', () async {
      expect(Future.value(1), completion(equals(1)));
      expect(const <int>[], isEmpty);
      await expectLater(Future.value(2), completion(2));
    });
  });

  group('pricing', () {
    test('applies a percentage discount', () {
      final result = applyRules(
        base: Money.cents(1000, 'EUR'),
        rules: [PercentageOff(10)],
      );
      expect(result.amount.cents, 900);
    }, skip: false);
  });
}
MatcherPurposeExample
equals(x)Deep equalityexpect([1], equals([1]))
same(x)Identityexpect(a, same(b))
isA<T>()Type checkthrowsA(isA<FormatException>())
throwsArgumentErrorA specific exceptionexpect(f, throwsArgumentError)
completion(m)A future resultexpect(f, completion(1))
contains(m)Collection or substringexpect(s, contains('a'))
predicate<T>Custom conditionexpect(n, predicate<int>((v) => v > 0, 'positive'))

expectLater returns a Future that must be awaited, which is what makes completion and emits reliable. Forgetting the await makes the test pass before the future resolves.

Testing asynchronous code

import 'package:fake_async/fake_async.dart';
import 'package:test/test.dart';

class Cache {
  Cache(this._loader, {this.ttl = const Duration(minutes: 5)});
  final Future<String> Function(String key) _loader;
  final Duration ttl;
  final _entries = <String, String>{};

  Future<String> get(String key) async {
    final cached = _entries[key];
    if (cached != null) return cached;
    final value = await _loader(key);
    _entries[key] = value;
    return value;
  }
}

void main() {
  test('uses a fake clock instead of waiting', () {
    fakeAsync((async) {
      var calls = 0;
      final cache = Cache((key) async {
        calls++;
        await Future<void>.delayed(const Duration(seconds: 30));
        return 'value-$key';
      });

      String? result;
      cache.get('a').then((v) => result = v);

      expect(result, isNull);            // still pending
      async.elapse(const Duration(seconds: 31));
      expect(result, 'value-a');

      cache.get('a').then((v) => result = v);
      async.flushMicrotasks();
      expect(calls, 1);                  // served from the cache
    });
  });

  test('streams can be asserted without a subscription', () {
    final controller = StreamController<int>();
    expect(controller.stream, emitsInOrder([1, 2, emitsDone]));
    controller..add(1)..add(2)..close();
  });

  test('a timeout is part of the contract', () {
    expect(
      Future<void>.delayed(const Duration(seconds: 2)),
      throwsA(isA<TimeoutException>()),
      timeout: const Timeout(Duration(milliseconds: 100)),
    );
  });
}

class StreamController<T> {
  StreamController();
  final Stream<T> stream = const Stream.empty();
  void add(T value) {}
  void close() {}
}

class TimeoutException implements Exception {}
  • fakeAsync replaces the event loop, so a delay of thirty seconds completes instantly. Use it for retry logic, debounce and polling.
  • A test with a real Future.delayed makes the suite slow and flaky. If you must wait, use a short timeout and assert on the observable result.
  • Stream matchers such as emitsInOrder, emitsError and emitsDone express the whole sequence in one line.
  • Call addTearDown for anything created in a test. It runs even when the test fails, which is what keeps the suite from leaking streams and timers.

Fakes, mocks and integration

import 'package:test/test.dart';

abstract interface class Clock {
  DateTime now();
}

class FixedClock implements Clock {
  FixedClock(this._now);
  final DateTime _now;
  @override
  DateTime now() => _now;
}

class OrderService {
  OrderService(this._clock);
  final Clock _clock;
  DateTime createdAt() => _clock.now();
}

void main() {
  test('a hand-written fake makes time deterministic', () {
    final svc = OrderService(FixedClock(DateTime.utc(2026, 9, 18, 12)));
    expect(svc.createdAt(), DateTime.utc(2026, 9, 18, 12));
  });

  test('mocktail records and stubs interactions', () {
    // class FakeRepo extends Mock implements Repository {}
    // final repo = FakeRepo();
    // when(() => repo.find('x')).thenAnswer((_) async => null);
    // verify(() => repo.find('x')).called(1);
  });

  test('an in-memory repository is often the better fake', () async {
    final repo = InMemoryRepository();
    await repo.save(const Order(id: 'o1', sku: 'ABC-1', quantity: 1));
    expect((await repo.find('o1'))?.sku, 'ABC-1');
  });
}

class Order {
  const Order({required this.id, required this.sku, required this.quantity});
  final String id;
  final String sku;
  final int quantity;
}

class InMemoryRepository {
  final _items = <String, Order>{};
  Future<void> save(Order o) async => _items[o.id] = o;
  Future<Order?> find(String id) async => _items[id];
}
dart test
dart test test/money_test.dart
dart test --name "currency"
dart test --coverage=coverage
dart run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info
dart test -p vm,chrome          # run on several platforms
💡
A hand-written fake beats a mocking framework when the interface is small: it is explicit, it survives a refactor of the interface with a compile error rather than a silent no-op, and it needs no verification vocabulary in the test body.

FAQ

How much should I mock?
As little as possible. Mock what you cannot control: the clock, the network, the file system. Use a real implementation for anything pure, and an in-memory implementation for a repository. Tests built on mocks verify your call sequence, not your behaviour.
Why does my async test pass locally and fail in CI?
Usually a race that resolves in a different order on a slower machine, or a shared resource such as a port or a temporary directory. Use fakeAsync, inject the clock, and give each test its own temporary directory.

Packages, pubspec and project layout Errors, exceptions and async error handling

Last refreshed 2026-09-18.