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);
});
}| Matcher | Purpose | Example |
|---|---|---|
equals(x) | Deep equality | expect([1], equals([1])) |
same(x) | Identity | expect(a, same(b)) |
isA<T>() | Type check | throwsA(isA<FormatException>()) |
throwsArgumentError | A specific exception | expect(f, throwsArgumentError) |
completion(m) | A future result | expect(f, completion(1)) |
contains(m) | Collection or substring | expect(s, contains('a')) |
predicate<T> | Custom condition | expect(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 {}fakeAsyncreplaces 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.delayedmakes the suite slow and flaky. If you must wait, use a short timeout and assert on the observable result. - Stream matchers such as
emitsInOrder,emitsErrorandemitsDoneexpress the whole sequence in one line. - Call
addTearDownfor 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.Related
Packages, pubspec and project layout Errors, exceptions and async error handling
Last refreshed 2026-09-18.