Watch the video (10 min)
On this page22 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. The Code We Will Test
  5. 2. throwsA and the Closure
  6. 3. The Shortcut Matchers
  7. 4. Checking the Message with having
  8. 5. Testing a Custom Exception
  9. 6. async Tests
  10. 7. Matchers for Futures
  11. 8. Matchers for Streams
  12. 9. Timeouts
  13. 10. A Real-World Example
  14. Code Examples
  15. Common Beginner Mistakes
  16. Best Practices
  17. Mini Challenge
  18. Practice Questions
  19. Practice Answers
  20. Quick Revision
  21. Key Takeaways
  22. What's Next?

Lecture 121 — Testing Exceptions & Async Code

Category20 — Testing
Lecture121 of 140
Estimated video length10 min
TargetDart 3.12.2
LevelAdvanced

So far our tests checked values a function returns. But good code also throws on bad input, and much of it returns a Future or a Stream. The test package 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, and await (Lectures 088–089)
  • streams and StreamController (Lectures 092–093)
  • test, expect, matchers such as equals and contains, and dart 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:

dart
test('rejects a negative amount', () {
  expect(withdraw(1000, -50), 1050);
});
output
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 throwsA and isA<T>()
  • Use the shortcuts throwsArgumentError, throwsStateError, and throwsFormatException
  • Check an exception's message and fields with having and predicate
  • Write async tests that await a Future
  • Test futures with completion, completes, and throwsA
  • Test streams with emits, emitsInOrder, emitsDone, and emitsError
  • 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:

dart
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:

dart
test('rejects a negative amount', () {
  expect(() => withdraw(1000, -50), throwsA(isA<ArgumentError>()));
});
output
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; expect runs 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:

dart
test('built-in errors', () {
  expect(() => withdraw(1000, 0), throwsArgumentError);
  expect(() => int.parse('ten'), throwsFormatException);
  expect(() => <int>[].first, throwsStateError);
});
output
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:

dart
test('explains the problem', () {
  expect(
    () => withdraw(1000, -50),
    throwsA(
      isA<ArgumentError>().having(
        (e) => e.message,
        'message',
        contains('positive'),
      ),
    ),
  );
});
output
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:

dart
test('reports balance and amount', () {
  expect(
    () => withdraw(300, 500),
    throwsA(
      isA<InsufficientFundsException>()
          .having((e) => e.balance, 'balance', 300)
          .having((e) => e.amount, 'amount', 500),
    ),
  );
});
output
00:00 +1: All tests passed!

Change 500 to 400 in the matcher, and the failure names the field:

output
  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:

dart
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:

dart
test('loads the balance', () async {
  var balance = await fetchBalance('Ali');
  expect(balance, 1000);
});
output
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:

dart
test('future matchers', () async {
  await expectLater(fetchBalance('Sara'), completion(1000));
  await expectLater(fetchBalance('Sara'), completes);
  await expectLater(fetchBalance(''), throwsA(isA<StateError>()));
});
output
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:

dart
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]),
  );
});
output
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:

output
  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:

dart
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)));
output
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:

dart
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]));
  });
}
output
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

dart
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>()));
  });
}
output
00:00 +1: All tests passed!

Example 2 — Shortcut Matchers

dart
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);
  });
}
output
00:00 +1: All tests passed!

Example 3 — Checking the Message

dart
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')),
    );
  });
}
output
00:00 +1: All tests passed!

FormatException keeps the bad text in its source field.

Example 4 — predicate

dart
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'),
      ),
    );
  });
}
output
00:00 +1: All tests passed!

Example 5 — An async Test

dart
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')));
  });
}
output
00:00 +2: All tests passed!

Example 6 — emits and emitsDone

dart
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]));
  });
}
output
00:00 +1: All tests passed!

Example 7 — Student Marks

dart
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);
  });
}
output
00:00 +2: All tests passed!

Example 8 — Shopping Total

dart
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')),
    );
  });
}
output
00:00 +1: All tests passed!

The having check proves the exception names the right item, not just any item.

Example 9 — Bank Balance

dart
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);
  });
}
output
00:00 +1: All tests passed!

The last line checks that a rejected deposit left the balance unchanged.

Example 10 — Temperature Readings

dart
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]),
    );
  });
}
output
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

dart
// expect(withdraw(1000, -50), throwsArgumentError); // ✗
expect(() => withdraw(1000, -50), throwsArgumentError);
output
00:00 +0 -1: rejects a negative amount [E]
  Invalid argument(s): Amount must be positive: -50

Without () =>, withdraw runs before expect is called, so nothing catches the throw.

2. Comparing a Future with a value

dart
// expect(fetchBalance('Ali'), 1000); // ✗
await expectLater(fetchBalance('Ali'), completion(1000));
output
  Expected: <1000>
    Actual: <Instance of 'Future<int>'>

3. Forgetting await in the test

dart
test('balance is 2000', () {
  fetchBalance('Ali').then((balance) {
    expect(balance, 2000); // ✗ runs after the test is over
  });
});
output
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

output
  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 Future async, and await each expectLater.
  • End stream checks with emitsDone when the stream must close.
  • Use a short timeout for 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
dart
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'),
        ),
      ),
    );
  });
}
output
00:00 +1: All tests passed!

Practice Questions

Work through these in order — they run from beginner to application to challenge.

Beginner

  1. Test that int.parse('abc') throws, using a shortcut matcher.
  2. Test that <int>[].first throws a StateError, using throwsA and isA.
  3. This line makes the test crash instead of passing. Fix it: expect(int.parse('x'), throwsFormatException);

Application

  1. void checkMarks(int m) throws ArgumentError('Marks out of range: $m') when m > 100. Test that the message contains 'out of range'.
  2. Future<int> loadScore() waits 50 ms and returns 90. Test it twice: once with await, once with completion.
  3. Future<int> loadScore(String name) throws StateError for an empty name. Test it with expectLater.
  4. Test that Stream.fromIterable(['Ali', 'Sara']) emits both names and then closes.

Challenge

  1. Why does this test pass, and how do you fix it?

    dart
    test('total', () {
      Future.value(100).then((total) => expect(total, 200));
    });
  2. class LowBalanceException implements Exception { final int needed; … }. A function pay(int balance, int price) throws it with needed = price - balance. Test that pay(100, 250) throws one with needed equal to 150.

  3. A StreamController<double> adds 20.0, 25.0, then a FormatException, 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

dart
test('parse fails', () {
  expect(() => int.parse('abc'), throwsFormatException);
});
output
00:00 +1: All tests passed!

Question 2 — Solution

dart
test('empty list', () {
  expect(() => <int>[].first, throwsA(isA<StateError>()));
});
output
00:00 +1: All tests passed!

Question 3 — Solution

Add the closure, so expect runs the call itself:

dart
expect(() => int.parse('x'), throwsFormatException);

Question 4 — Solution

dart
test('marks message', () {
  expect(
    () => checkMarks(150),
    throwsA(
      isA<ArgumentError>().having(
        (e) => e.message,
        'message',
        contains('out of range'),
      ),
    ),
  );
});
output
00:00 +1: All tests passed!

Question 5 — Solution

dart
test('with await', () async {
  expect(await loadScore(), 90);
});
test('with completion', () async {
  await expectLater(loadScore(), completion(90));
});
output
00:00 +2: All tests passed!

Question 6 — Solution

dart
test('empty name', () async {
  await expectLater(loadScore(''), throwsStateError);
});
output
00:00 +1: All tests passed!

Question 7 — Solution

dart
test('names', () async {
  var names = Stream.fromIterable(['Ali', 'Sara']);
  await expectLater(names, emitsInOrder(['Ali', 'Sara', emitsDone]));
});
output
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:

dart
test('total', () async {
  expect(await Future.value(100), 200);
});
output
  Expected: <200>
    Actual: <100>

Now the test fails, as it should — fix the expected value or the code under test.

Question 9 — Solution

dart
test('needed amount', () {
  expect(
    () => pay(100, 250),
    throwsA(
      isA<LowBalanceException>().having((e) => e.needed, 'needed', 150),
    ),
  );
});
output
00:00 +1: All tests passed!

Question 10 — Solution

dart
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)));
output
00:00 +1: All tests passed!

This file also needs import 'dart:async';.


Quick Revision

  1. Why must the call inside throwsA be wrapped in () =>?
  2. What is throwsStateError short for?
  3. Which matcher adds a check on one field of an exception?
  4. What does completion(5) check? And completes?
  5. Why do you await an expectLater?
  6. What does emitsDone check?
  7. 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, throwsFormatException and friends check the type.
  • having checks a field such as message; predicate checks any condition.
  • Custom exceptions are tested the same way, one having per field.
  • Test bodies can be async; the runner waits for them.
  • Match futures with completion, completes, and throwsA, using await expectLater.
  • Match streams with emits, emitsInOrder, emitsError, and emitsDone.
  • A forgotten await can 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.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.