06 · Testing Advanced¶
Level 2's testing module covered the basics —
test(), expect(), and simple matchers. Real test suites also need to
isolate code from time, randomness, and slow dependencies, and to assert on
asynchronous behavior correctly. This module covers setUp/group, hand-
rolled fakes (no mocking framework needed for most cases), and the async
matcher traps that catch people out.
group and setUp: shared fixtures without duplication¶
group() names a related batch of tests; setUp() runs before each
test in its group, giving every test a fresh fixture instead of a shared,
possibly-mutated one.
import 'package:test/test.dart';
abstract class Clock {
DateTime now();
}
class FakeClock implements Clock {
FakeClock(this._time);
DateTime _time;
@override
DateTime now() => _time;
void advance(Duration d) => _time = _time.add(d);
}
class Session {
Session(this._clock, {Duration timeout = const Duration(minutes: 5)})
: _timeout = timeout,
_started = _clock.now();
final Clock _clock;
final Duration _timeout;
final DateTime _started;
bool get isExpired => _clock.now().difference(_started) > _timeout;
}
void main() {
group('Session with a fake clock', () {
late FakeClock clock;
setUp(() {
clock = FakeClock(DateTime(2024, 1, 1)); // fresh clock for every test
});
test('is not expired immediately', () {
final session = Session(clock);
expect(session.isExpired, isFalse);
});
test('expires after the timeout elapses', () {
final session = Session(clock, timeout: const Duration(minutes: 1));
clock.advance(const Duration(minutes: 2));
expect(session.isExpired, isTrue);
});
});
}
The Clock interface is the real trick here: Session never calls
DateTime.now() directly, so tests control time completely instead of
sleep-ing real seconds to observe a timeout.
Fakes over mocking frameworks¶
A hand-written fake implementing an interface is often clearer than a mocking library, and needs no extra dependency:
abstract class Repository {
Future<String> fetchName(int id);
}
class FakeRepository implements Repository {
final Map<int, String> data = {1: 'Ada'};
int callCount = 0;
@override
Future<String> fetchName(int id) async {
callCount++;
final name = data[id];
if (name == null) throw StateError('not found: $id');
return name;
}
}
void main() {
group('Repository fake', () {
test('returns a known name', () async {
final repo = FakeRepository();
final name = await repo.fetchName(1);
expect(name, equals('Ada'));
expect(repo.callCount, 1); // fakes can also assert on interactions
});
test('throws for an unknown id', () {
final repo = FakeRepository();
expect(() => repo.fetchName(2), throwsA(isA<StateError>()));
});
});
}
repo.callCount shows the other advantage of a fake over a "pure" test
double: it can track how it was used and let a test assert on that, exactly
like a mock's verify step, with no extra API to learn.
Async matchers: completion and throwsA¶
expect() can take a Future directly if the matcher understands async —
completion() unwraps a Future's success value; throwsA() (used above)
unwraps its failure.
test('async matcher: completes with a value', () {
Future<int> compute() async {
await Future.delayed(const Duration(milliseconds: 10));
return 42;
}
expect(compute(), completion(equals(42)));
});
test() itself doesn't need to be async here — completion() returns a
matcher that the test runner awaits internally, and it registers the
expectation as part of the test's async work so the test doesn't finish
(and pass falsely) before the future resolves.
The trap: throwsA needs a closure, not a called function¶
throwsA matches an exception thrown while expect evaluates its
matcher — which only works if the thing under test hasn't already run (and
thrown) before expect is even called.
int boom() => throw StateError('bad');
void main() {
test('trap: calling eagerly throws before expect runs', () {
expect(boom(), throwsA(isA<StateError>()));
});
}
trap: calling eagerly throws before expect runs [E]
Bad state: bad
test/trap_test.dart 3:15 boom
test/trap_test.dart 7:12 main.<fn>
boom() throws while Dart evaluates the argument list, before expect is
ever entered — the test fails with an uncaught exception rather than a
matcher mismatch. The fix is always to pass a closure so expect controls
when the call happens: expect(() => boom(), throwsA(isA<StateError>())).
This is exactly the same shape of mistake as FakeRepository's working
expect(() => repo.fetchName(2), throwsA(...)) above — note the () =>.
Cheat sheet¶
| Concept | Meaning |
|---|---|
group(name, () {...}) |
Namespace and organize related tests |
setUp(() {...}) |
Runs before every test in the enclosing group — fresh fixtures |
tearDown(() {...}) |
Runs after every test — release resources |
| Fake implementing an interface | Test double with real (simplified) behavior, no framework needed |
completion(matcher) |
Assert on the value a Future completes with |
throwsA(matcher) |
Assert an exception is thrown — needs () => ..., not a called value |
isA<T>() |
Matcher: value is an instance of T |
How It Actually Works¶
throwsA and other asynchronous/exception matchers work because
package:matcher doesn't inspect a value — it wraps a closure and
invokes it itself inside a controlled try/catch, capturing whatever
exception (or lack of one) results. This is exactly why throwsA needs an
uncalled closure (() => doTheThing()) rather than the already-evaluated
result of calling the function (doTheThing()): if you call the function
yourself when passing it to expect, the exception is thrown immediately,
during argument evaluation, before expect/throwsA ever gets a chance to
install its try/catch around the call — the exception propagates out of
your test function directly instead of being captured as an expected
outcome.
completion() similarly wraps a Future rather than a resolved value —
internally it registers a .then()/.catchError() pair on the future and
tells the test runner's zone-based scheduler (the same Zone mechanism
that lets the runner attribute async errors to specific tests) to keep the
test alive until that future settles, then asserts against the eventual
result or error.
Fakes over mocking frameworks is a design choice that interacts with Dart's static, sound type system: a hand-written fake is a real class implementing the same interface, so the compiler checks it structurally just like any other implementation — a fake naturally stays in sync with interface changes (a signature change is a compile error in the fake too), whereas a fully dynamic mock built via reflection-like intercepting proxies can silently drift out of sync with an interface it doesn't statically implement, an outcome Dart's AOT-compiled, tree-shaken runtime model (which discourages heavy runtime reflection) makes actively awkward to rely on anyway.
Exercise¶
Write a RetryingClient class that takes a Future<String> Function()
fetcher and a Clock, and retries the fetcher up to 3 times with a 1-second
fake-clock delay between attempts if it throws, returning the first
successful result or rethrowing after the third failure. Test it with a
group that uses setUp to build a fetcher-call counter, covering: succeeds
on the first try, succeeds on the third try after two failures, and
exhausts all retries and rethrows. Use throwsA correctly (with a closure)
for the failing case.