On this page22 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. The Code We Will Test
- 2. throwsA and the Closure
- 3. The Shortcut Matchers
- 4. Checking the Message with having
- 5. Testing a Custom Exception
- 6. async Tests
- 7. Matchers for Futures
- 8. Matchers for Streams
- 9. Timeouts
- 10. A Real-World Example
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 121 — Testing Exceptions & Async Code
| Category | 20 — Testing |
| Lecture | 121 of 140 |
| Estimated video length | 10 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
So far our tests checked values a function returns. But good code also throws on bad input, and much of it returns a
Futureor aStream. Thetestpackage has matchers for all three.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2, so you will recognize it when you meet it yourself.
Prerequisites
Students should already understand, from Lectures 083–120:
throw,ArgumentError,StateError,FormatException, and custom exceptions (Lecture 085)Future,async, andawait(Lectures 088–089)- streams and
StreamController(Lectures 092–093) test,expect, matchers such asequalsandcontains, anddart test(Lectures 118–120)
Connection With Previous Lectures
In Lecture 085, Ali's withdraw function throws an ArgumentError for a negative amount. That
throw is part of the function's job, so it deserves a test. Here is a first attempt:
test('rejects a negative amount', () {
expect(withdraw(1000, -50), 1050);
});00:00 +0 -1: rejects a negative amount [E]
Invalid argument(s): Amount must be positive: -50
package:bank_app/bank_app.dart 12:5 withdraw
test/bank_test.dart 6:12 main.<fn>The function did exactly the right thing, yet the test failed: the exception escaped before
expect could look at anything. We need a way to say "this call should throw".
Learning Objectives
By the end of this lecture you will be able to:
- Test that a call throws with
throwsAandisA<T>() - Use the shortcuts
throwsArgumentError,throwsStateError, andthrowsFormatException - Check an exception's message and fields with
havingandpredicate - Write
asynctests thatawaitaFuture - Test futures with
completion,completes, andthrowsA - Test streams with
emits,emitsInOrder,emitsDone, andemitsError - Recognize the forgotten closure and the forgotten
await - Give a slow test a
timeout
1. The Code We Will Test
We use a package created with dart create -t package bank_app. Its lib/bank_app.dart
holds this code from Lectures 085 and 089:
class InsufficientFundsException implements Exception {
final int balance;
final int amount;
InsufficientFundsException(this.balance, this.amount);
@override
String toString() => 'Cannot withdraw $amount, balance is $balance';
}
int withdraw(int balance, int amount) {
if (amount <= 0) {
throw ArgumentError('Amount must be positive: $amount');
}
if (amount > balance) {
throw InsufficientFundsException(balance, amount);
}
return balance - amount;
}
Future<int> fetchBalance(String owner) async {
await Future.delayed(Duration(milliseconds: 100));
if (owner.isEmpty) {
throw StateError('No account owner');
}
return 1000;
}Every test below lives inside main() of test/bank_test.dart, which starts with
import 'package:bank_app/bank_app.dart'; and import 'package:test/test.dart';.
2. throwsA and the Closure
Wrap the call in a closure — () => … — and match it with throwsA:
test('rejects a negative amount', () {
expect(() => withdraw(1000, -50), throwsA(isA<ArgumentError>()));
});00:00 +1: All tests passed!Analogy — a crash-test lab does not drive the car into a wall itself. It hands the car to the machine, which runs the crash under control and reports what happened. The closure is the car you hand over;
expectruns it, catches the exception, and inspects it.
isA<ArgumentError>() is a matcher that checks an object's type.
3. The Shortcut Matchers
The common built-in errors have ready-made matchers:
test('built-in errors', () {
expect(() => withdraw(1000, 0), throwsArgumentError);
expect(() => int.parse('ten'), throwsFormatException);
expect(() => <int>[].first, throwsStateError);
});00:00 +1: All tests passed!throwsArgumentError is simply short for throwsA(isA<ArgumentError>()). There is also
throwsRangeError, throwsUnsupportedError, and throwsException (any Exception).
4. Checking the Message with having
The type alone may not be enough. having adds a check on one field: a function that
reads it, a name for the report, and the expected value or matcher:
test('explains the problem', () {
expect(
() => withdraw(1000, -50),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('positive'),
),
),
);
});00:00 +1: All tests passed!5. Testing a Custom Exception
Our own exception class from Lecture 085 works the same way. Chain one having per field:
test('reports balance and amount', () {
expect(
() => withdraw(300, 500),
throwsA(
isA<InsufficientFundsException>()
.having((e) => e.balance, 'balance', 300)
.having((e) => e.amount, 'amount', 500),
),
);
});00:00 +1: All tests passed!Change 500 to 400 in the matcher, and the failure names the field:
Expected: throws <Instance of 'InsufficientFundsException'> with `amount`: <400>
Actual: <Closure: () => int>
Which: threw InsufficientFundsException:<Cannot withdraw 500, balance is 300>
...
which has `amount` with value <500>For a check that spans several fields, predicate takes any bool function and a
description:
expect(
() => withdraw(300, 500),
throwsA(
predicate<InsufficientFundsException>(
(e) => e.amount - e.balance == 200,
'is 200 short',
),
),
);6. async Tests
A test body can be async. The runner waits for its Future before it decides the result:
test('loads the balance', () async {
var balance = await fetchBalance('Ali');
expect(balance, 1000);
});00:00 +1: All tests passed!7. Matchers for Futures
Instead of awaiting the value yourself, you can match the Future directly. Use
expectLater and await it:
test('future matchers', () async {
await expectLater(fetchBalance('Sara'), completion(1000));
await expectLater(fetchBalance('Sara'), completes);
await expectLater(fetchBalance(''), throwsA(isA<StateError>()));
});00:00 +1: All tests passed!completion(x) means "finishes with x", completes means "finishes without an error", and
throwsA works on a Future too — no closure needed, because a Future is already a
delayed result.
8. Matchers for Streams
Stream matchers describe the events in order. emitsDone checks that the stream closes, and
emitsError that an error arrives:
test('stream events', () async {
var controller = StreamController<int>();
controller.add(100);
controller.addError(StateError('Account closed'));
controller.close();
await expectLater(
controller.stream,
emitsInOrder([100, emitsError(isStateError), emitsDone]),
);
});00:00 +1: All tests passed!StreamController needs import 'dart:async';. A single emits(100) checks only the next
event. When the stream does something else, the report lists what it really emitted:
Expected: should do the following in order:
• emit an event that <100>
• emit an event that <300>
Actual: <Instance of '_MultiStream<int>'>
Which: emitted • 100
• 200
x Stream closed.
which didn't emit an event that <300>9. Timeouts
A test that waits forever would stall the whole run, so every test has a 30-second
limit. Set your own with timeout:
Future<int> slowBalance() async {
await Future.delayed(Duration(seconds: 3));
return 1000;
}
// inside main():
test('answers quickly', () async {
expect(await slowBalance(), 1000);
}, timeout: Timeout(Duration(seconds: 1)));00:01 +0 -1: answers quickly [E]
TimeoutException after 0:00:01.000000: Test timed out after 1 seconds.10. A Real-World Example
One file tests Ali's account from every side — success, each failure, the future, and a stream of balances:
import 'package:bank_app/bank_app.dart';
import 'package:test/test.dart';
void main() {
test('withdraw subtracts', () {
expect(withdraw(1000, 300), 700);
});
test('withdraw rejects bad amounts', () {
expect(() => withdraw(1000, -5), throwsArgumentError);
expect(
() => withdraw(200, 500),
throwsA(
isA<InsufficientFundsException>().having(
(e) => e.amount,
'amount',
500,
),
),
);
});
test('fetchBalance', () async {
await expectLater(fetchBalance('Ali'), completion(1000));
await expectLater(fetchBalance(''), throwsStateError);
});
test('balance history', () async {
var history = Stream.fromIterable([1000, 700, 400]);
await expectLater(history, emitsInOrder([1000, 700, 400, emitsDone]));
});
}00:00 +4: All tests passed!Code Examples
Each example is one file in test/; run it with dart test test/<file>.dart. The line
shown is the last line of the output.
Example 1 — throwsA with isA
import 'package:test/test.dart';
void setAge(int age) {
if (age < 0) throw ArgumentError('Age cannot be negative');
}
void main() {
test('negative age throws', () {
expect(() => setAge(-1), throwsA(isA<ArgumentError>()));
});
}00:00 +1: All tests passed!Example 2 — Shortcut Matchers
import 'package:test/test.dart';
void main() {
test('built-in errors', () {
expect(() => int.parse('abc'), throwsFormatException);
expect(() => <String>[].first, throwsStateError);
expect(() => [1, 2][5], throwsRangeError);
});
}00:00 +1: All tests passed!Example 3 — Checking the Message
import 'package:test/test.dart';
void main() {
test('message names the input', () {
expect(
() => int.parse('ten'),
throwsA(isA<FormatException>().having((e) => e.source, 'source', 'ten')),
);
});
}00:00 +1: All tests passed!FormatException keeps the bad text in its source field.
Example 4 — predicate
import 'package:test/test.dart';
void main() {
test('message is short', () {
expect(
() => throw StateError('Closed'),
throwsA(
predicate<StateError>((e) => e.message.length < 10, 'short message'),
),
);
});
}00:00 +1: All tests passed!Example 5 — An async Test
import 'package:test/test.dart';
Future<String> fetchName() async {
await Future.delayed(Duration(milliseconds: 50));
return 'Sara';
}
void main() {
test('awaiting the value', () async {
expect(await fetchName(), 'Sara');
});
test('matching the future', () async {
await expectLater(fetchName(), completion(startsWith('S')));
});
}00:00 +2: All tests passed!Example 6 — emits and emitsDone
import 'package:test/test.dart';
void main() {
test('names in order', () async {
var names = Stream.fromIterable(['Ali', 'Sara', 'Zayan']);
await expectLater(names, emitsInOrder(['Ali', 'Sara', 'Zayan', emitsDone]));
});
}00:00 +1: All tests passed!Example 7 — Student Marks
import 'package:test/test.dart';
String grade(int marks) {
if (marks < 0 || marks > 100) throw RangeError('Marks out of range: $marks');
return marks >= 50 ? 'Pass' : 'Fail';
}
void main() {
test('valid marks', () {
expect(grade(80), 'Pass');
expect(grade(30), 'Fail');
});
test('invalid marks', () {
expect(() => grade(120), throwsRangeError);
expect(() => grade(-5), throwsRangeError);
});
}00:00 +2: All tests passed!Example 8 — Shopping Total
import 'package:test/test.dart';
class OutOfStockException implements Exception {
final String item;
OutOfStockException(this.item);
}
Future<int> fetchPrice(String item) async {
await Future.delayed(Duration(milliseconds: 50));
if (item == 'Bag') throw OutOfStockException(item);
return 500;
}
void main() {
test('price and stock', () async {
await expectLater(fetchPrice('Notebook'), completion(500));
await expectLater(
fetchPrice('Bag'),
throwsA(isA<OutOfStockException>().having((e) => e.item, 'item', 'Bag')),
);
});
}00:00 +1: All tests passed!The having check proves the exception names the right item, not just any item.
Example 9 — Bank Balance
import 'package:test/test.dart';
class Account {
int _balance = 0;
int get balance => _balance;
void deposit(int amount) {
if (amount <= 0) throw ArgumentError.value(amount, 'amount');
_balance += amount;
}
}
void main() {
test('deposit', () {
var account = Account();
account.deposit(500);
expect(account.balance, 500);
expect(() => account.deposit(0), throwsArgumentError);
expect(account.balance, 500);
});
}00:00 +1: All tests passed!The last line checks that a rejected deposit left the balance unchanged.
Example 10 — Temperature Readings
import 'package:test/test.dart';
Stream<double> readings(List<double> values) async* {
for (var value in values) {
if (value < -90) throw FormatException('Impossible reading: $value');
yield value;
}
}
void main() {
test('stops at a bad reading', () async {
await expectLater(
readings([21.5, 22.0, -300]),
emitsInOrder([21.5, 22.0, emitsError(isFormatException), emitsDone]),
);
});
}00:00 +1: All tests passed!An async* generator (Lecture 096) ends its stream after throwing, so emitsDone follows the
error.
Common Beginner Mistakes
1. Forgetting the closure
// expect(withdraw(1000, -50), throwsArgumentError); // ✗
expect(() => withdraw(1000, -50), throwsArgumentError);00:00 +0 -1: rejects a negative amount [E]
Invalid argument(s): Amount must be positive: -50Without () =>, withdraw runs before expect is called, so nothing catches the throw.
2. Comparing a Future with a value
// expect(fetchBalance('Ali'), 1000); // ✗
await expectLater(fetchBalance('Ali'), completion(1000)); Expected: <1000>
Actual: <Instance of 'Future<int>'>3. Forgetting await in the test
test('balance is 2000', () {
fetchBalance('Ali').then((balance) {
expect(balance, 2000); // ✗ runs after the test is over
});
});00:00 +1: All tests passed!The balance is 1000, yet the test passes: it finished before the Future did. When
another test follows, the runner may catch it late with This test failed after it had already completed. Make the test async and await the value.
4. Expecting a shortcut to accept any error
Expected: throws <Instance of 'ArgumentError'>
Actual: <Closure: () => int>
Which: threw InsufficientFundsException:<Cannot withdraw 500, balance is 300>throwsArgumentError matches only that type. A call that throws something else — or nothing
(Which: returned <950>) — fails.
5. Leaving out await before expectLater
Without it, the lines after expectLater run before the check finishes. Always write
await expectLater(...).
Best Practices
- Test the failure paths, not just the happy path.
- Check the exception's fields with
having— the type alone can hide a wrong message. - Prefer the shortcut matchers when only the type matters.
- Make every test that touches a
Futureasync, andawaiteachexpectLater. - End stream checks with
emitsDonewhen the stream must close. - Use a short
timeoutfor code that should be quick.
Mini Challenge
Zayan's piggy bank: Future<int> spend(int coins, int cost) waits 50 ms, throws a
StateError('Not enough coins') when cost > coins, and otherwise returns what is left.
Write one test that checks spend(10, 4) gives 6, and that spend(3, 5) throws a
StateError whose message contains 'Not enough'.
Try it yourself before opening the solution.
Show solution
import 'package:test/test.dart';
Future<int> spend(int coins, int cost) async {
await Future.delayed(Duration(milliseconds: 50));
if (cost > coins) throw StateError('Not enough coins');
return coins - cost;
}
void main() {
test('piggy bank', () async {
await expectLater(spend(10, 4), completion(6));
await expectLater(
spend(3, 5),
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
contains('Not enough'),
),
),
);
});
}00:00 +1: All tests passed!Practice Questions
Work through these in order — they run from beginner to application to challenge.
Beginner
- Test that
int.parse('abc')throws, using a shortcut matcher. - Test that
<int>[].firstthrows aStateError, usingthrowsAandisA. - This line makes the test crash instead of passing. Fix it:
expect(int.parse('x'), throwsFormatException);
Application
void checkMarks(int m)throwsArgumentError('Marks out of range: $m')whenm > 100. Test that the message contains'out of range'.Future<int> loadScore()waits 50 ms and returns90. Test it twice: once withawait, once withcompletion.Future<int> loadScore(String name)throwsStateErrorfor an empty name. Test it withexpectLater.- Test that
Stream.fromIterable(['Ali', 'Sara'])emits both names and then closes.
Challenge
-
Why does this test pass, and how do you fix it?
test('total', () { Future.value(100).then((total) => expect(total, 200)); }); -
class LowBalanceException implements Exception { final int needed; … }. A functionpay(int balance, int price)throws it withneeded = price - balance. Test thatpay(100, 250)throws one withneededequal to150. -
A
StreamController<double>adds20.0,25.0, then aFormatException, then closes. Test the whole sequence, with a 2-second timeout.
Practice Answers
Each answer is the body of main() in a file that imports package:test/test.dart.
Question 1 — Solution
test('parse fails', () {
expect(() => int.parse('abc'), throwsFormatException);
});00:00 +1: All tests passed!Question 2 — Solution
test('empty list', () {
expect(() => <int>[].first, throwsA(isA<StateError>()));
});00:00 +1: All tests passed!Question 3 — Solution
Add the closure, so expect runs the call itself:
expect(() => int.parse('x'), throwsFormatException);Question 4 — Solution
test('marks message', () {
expect(
() => checkMarks(150),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('out of range'),
),
),
);
});00:00 +1: All tests passed!Question 5 — Solution
test('with await', () async {
expect(await loadScore(), 90);
});
test('with completion', () async {
await expectLater(loadScore(), completion(90));
});00:00 +2: All tests passed!Question 6 — Solution
test('empty name', () async {
await expectLater(loadScore(''), throwsStateError);
});00:00 +1: All tests passed!Question 7 — Solution
test('names', () async {
var names = Stream.fromIterable(['Ali', 'Sara']);
await expectLater(names, emitsInOrder(['Ali', 'Sara', emitsDone]));
});00:00 +1: All tests passed!Question 8 — Solution
The test ends before the then callback runs, so the failing expect comes too late. Await
the value:
test('total', () async {
expect(await Future.value(100), 200);
}); Expected: <200>
Actual: <100>Now the test fails, as it should — fix the expected value or the code under test.
Question 9 — Solution
test('needed amount', () {
expect(
() => pay(100, 250),
throwsA(
isA<LowBalanceException>().having((e) => e.needed, 'needed', 150),
),
);
});00:00 +1: All tests passed!Question 10 — Solution
test('temperatures', () async {
var controller = StreamController<double>();
controller.add(20.0);
controller.add(25.0);
controller.addError(FormatException('Bad reading'));
controller.close();
await expectLater(
controller.stream,
emitsInOrder([20.0, 25.0, emitsError(isFormatException), emitsDone]),
);
}, timeout: Timeout(Duration(seconds: 2)));00:00 +1: All tests passed!This file also needs import 'dart:async';.
Quick Revision
- Why must the call inside
throwsAbe wrapped in() =>? - What is
throwsStateErrorshort for? - Which matcher adds a check on one field of an exception?
- What does
completion(5)check? Andcompletes? - Why do you
awaitanexpectLater? - What does
emitsDonecheck? - How long may a test run by default, and how do you change it?
Key Takeaways
- Wrap a throwing call in a closure:
expect(() => f(), throwsA(isA<T>())). throwsArgumentError,throwsStateError,throwsFormatExceptionand friends check the type.havingchecks a field such asmessage;predicatechecks any condition.- Custom exceptions are tested the same way, one
havingper field. - Test bodies can be
async; the runner waits for them. - Match futures with
completion,completes, andthrowsA, usingawait expectLater. - Match streams with
emits,emitsInOrder,emitsError, andemitsDone. - A forgotten
awaitcan make a wrong test pass; a forgotten closure makes a right one crash. - Every test times out after 30 seconds unless you set
timeout.
What's Next?
Lecture 122 — Test Setup & Organization. Our test files keep growing, and many tests
start the same way — creating an account, filling a controller. Next we will share setup code
inside the groups from Lecture 119 with setUp and tearDown, and organize a test folder
that stays readable as the project grows.