On this page23 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. Plan the Suite
- 2. A Helper That Builds a Test App
- 3. Testing a Model
- 4. Tests Generated from a Map
- 5. Red → Green: Watch a Test Fail
- 6. Testing Exceptions in Plain Code
- 7. Testing Exceptions in Services
- 8. Comparing Records
- 9. Testing Files in a Temporary Folder
- 10. Testing the Event Stream
- 11. 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 138 — Project Testing
| Category | 23 — Final Dart Project |
| Lecture | 138 of 140 |
| Estimated video length | 20 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
The budget tracker works — but only because we tried each command by hand. Today we give it a test suite of 40 tests that checks models, files, services and streams in under a second, on any day, without touching the real
data/folder.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every output and error message in this lecture is real. Each one was produced with
dart teston Dart 3.12.2, so you will recognize it when you meet it yourself.
Prerequisites
Students should already understand, from Lectures 118–137:
test,expect, matchers andgroup(Lectures 118–119)- testing classes, edge cases and fakes (Lecture 120)
throwsA,having,expectLater,emitsInOrder,emitsDone(Lecture 121)setUp,tearDown,late, temporary folders andtest/helpers/(Lecture 122)- the budget tracker as built in Lectures 132–137: models, services, repositories,
BudgetApp, the event stream and theBudgetMonitor
Connection With Previous Lectures
At the end of Lecture 137 we checked the app the only way we had: run demo, spend some
money, read the wallets and the activity log, and compare with what we expected. That check
takes minutes, depends on today's date and on whatever is already in data/, and has to be
repeated after every change. In the next lecture we are going to change a lot of code.
Before that, the behaviour we checked by hand must be checked by code.
Learning Objectives
By the end of this lecture you will be able to:
- Organize a project's tests in a
test/folder that mirrorslib/src/ - Write a shared helper that builds a ready-made, in-memory app with a fake clock
- Test models, enums and extension types, including table-driven tests built from a map
- See a test fail on purpose (red → green) to prove it really checks something
- Test synchronous and asynchronous exceptions with
throwsA(isA<T>().having(...)) - Test file storage safely inside a temporary folder
- Test broadcast streams and an
async*generator withemitsInOrderandemitsDone - Recognize what the suite cannot see yet: the logic inside
bin/
1. Plan the Suite
The test folder mirrors lib/src/, one test file per area. Create these ten files and delete
the template's test/budget_tracker_test.dart, which only checked the version string:
test/
├── helpers/test_app.dart
├── models/money_test.dart transaction_test.dart wallet_budget_test.dart
├── extensions/iterable_extensions_test.dart
├── repository/json_file_repository_test.dart
└── services/ledger_service_test.dart budget_service_test.dart budget_monitor_test.dartNotice what does not change: nothing in lib/. In Lectures 134–137 we gave
LedgerService an optional clock, passed repositories in through constructors and added
BudgetApp.inMemory(). Those choices are what make the app testable today.
2. A Helper That Builds a Test App
Almost every service test needs the same starting point: an app with a couple of wallets.
Put that in a helper (no _test suffix, so it does not run as a test):
test/helpers/test_app.dart
import 'package:budget_tracker/budget_tracker.dart';
/// Every test app believes today is 15 October 2026.
final testToday = DateTime(2026, 10, 15);
/// An in-memory app with Ali (Rs 5000 allowance) and Sara (Rs 2000 opening).
Future<BudgetApp> seededApp() async {
final app = BudgetApp.inMemory(clock: () => testToday);
final ledger = app.ledger;
await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
await ledger.openWallet(
id: 'sara',
owner: 'Sara',
name: 'Bank card',
openingBalance: const Money(200000),
);
await ledger.recordIncome('ali', const Money(500000), source: 'Allowance');
return app;
}The clock () => testToday is the fake from Lecture 120: every transaction recorded without
a date gets 15 October 2026, whichever day you run the tests. Nothing is written to disk.
Afterwards Ali holds Rs 5000.00 (transaction t0001) and Sara Rs 2000.00.
3. Testing a Model
Models are pure Dart with no waiting and no files — the easiest place to start.
test/models/money_test.dart
import 'package:budget_tracker/budget_tracker.dart';
import 'package:test/test.dart';
void main() {
group('Money.tryParse', () {
test('reads whole and decimal amounts as cents', () {
expect(Money.tryParse('250'), const Money(25000));
expect(Money.tryParse(' 12.5 '), const Money(1250));
});
test('returns null for text that is not a number', () {
expect(Money.tryParse('abc'), isNull);
expect(Money.tryParse(''), isNull);
});
});
test('format shows rupees, paisa and a sign', () {
expect(const Money(123456).format(), 'Rs 1234.56');
expect(const Money(-300).format(), '-Rs 3.00');
expect(Money.zero.format(), 'Rs 0.00');
});
test('operators work on cents', () {
const a = Money(1000);
const b = Money(250);
expect(a + b, const Money(1250));
expect(a - b, const Money(750));
expect(-b, const Money(-250));
expect(a > b, isTrue);
});
}$ dart test test/models/money_test.dart
00:00 +0: loading test/models/money_test.dart
00:00 +0: Money.tryParse reads whole and decimal amounts as cents
00:00 +1: Money.tryParse returns null for text that is not a number
00:00 +2: format shows rupees, paisa and a sign
00:00 +3: operators work on cents
00:00 +4: All tests passed!4. Tests Generated from a Map
BudgetLevel.of has boundaries at 80 % and 100 %. Instead of four near-identical tests, a
map of cases generates them in a loop:
test/models/wallet_budget_test.dart (lines 29–46)
group('BudgetLevel.of', () {
const limit = Money(10000);
final cases = {
const Money(5000): BudgetLevel.ok,
const Money(8000): BudgetLevel.warning,
const Money(10000): BudgetLevel.warning,
const Money(10001): BudgetLevel.over,
};
cases.forEach((spent, level) {
test('${spent.format()} of ${limit.format()} is ${level.name}', () {
expect(BudgetLevel.of(spent, limit), level);
});
});
test('no limit means none', () {
expect(BudgetLevel.of(const Money(5000), null), BudgetLevel.none);
});
});Both sides of each boundary are covered: exactly Rs 80.00 is a warning; Rs 100.01 is over. The generated names tell you exactly which case broke.
5. Red → Green: Watch a Test Fail
A test that has never failed might not check anything. Temporarily change
warningPercent = 80 to 90 in lib/src/models/monthly_report.dart and run the file:
$ dart test test/models/wallet_budget_test.dart
00:00 +0: loading test/models/wallet_budget_test.dart
00:00 +0: Wallet.copyWith keeps the id and returns a new object
00:00 +1: Wallet JSON round trip gives an equal wallet
00:00 +2: Budget is identified by its category
00:00 +3: Category.tryParse ignores case and rejects unknown names
00:00 +4: BudgetLevel.of Rs 50.00 of Rs 100.00 is ok
00:00 +5: BudgetLevel.of Rs 80.00 of Rs 100.00 is warning
00:00 +5 -1: BudgetLevel.of Rs 80.00 of Rs 100.00 is warning [E]
Expected: BudgetLevel:<BudgetLevel.warning>
Actual: BudgetLevel:<BudgetLevel.ok>
package:matcher expect
test/models/wallet_budget_test.dart 39:9 main.<fn>.<fn>.<fn>
00:00 +5 -1: BudgetLevel.of Rs 100.00 of Rs 100.00 is warning
00:00 +6 -1: BudgetLevel.of Rs 100.01 of Rs 100.00 is over
00:00 +7 -1: BudgetLevel.of no limit means none
00:00 +8 -1: Some tests failed.
Failing tests:
test/models/wallet_budget_test.dart: BudgetLevel.of Rs 80.00 of Rs 100.00 is warning
Consider enabling the flag chain-stack-traces to receive more detailed exceptions.
For example, 'dart test --chain-stack-traces'.Exactly the boundary case fails. Change 90 back to 80 and the file is green again
(+9: All tests passed!).
6. Testing Exceptions in Plain Code
transaction_test.dart builds one transaction of each kind at the top of main(). They are
never changed, so sharing them is safe:
test/models/transaction_test.dart (lines 1–27)
import 'package:budget_tracker/budget_tracker.dart';
import 'package:test/test.dart';
void main() {
final date = DateTime(2026, 10, 3);
final income = Income(
id: 't1',
walletId: 'ali',
amount: const Money(5000),
date: date,
source: 'Allowance',
);
final expense = Expense(
id: 't2',
walletId: 'ali',
amount: const Money(1200),
date: date,
note: 'lunch',
category: Category.food,
);
final transfer = Transfer(
id: 't3',
walletId: 'ali',
amount: const Money(700),
date: date,
toWalletId: 'sara',
);test/models/transaction_test.dart (lines 42–60)
group('JSON', () {
test('every kind survives a round trip', () {
for (final original in [income, expense, transfer]) {
final copy = Transaction.fromJson(original.toJson());
expect(copy.runtimeType, original.runtimeType);
expect(copy.toJson(), original.toJson());
}
});
test('an unknown type is a FormatException', () {
final json = {...income.toJson(), 'type': 'gift'};
expect(() => Transaction.fromJson(json), throwsFormatException);
});
test('a missing field is a FormatException', () {
final json = income.toJson()..remove('amount');
expect(() => Transaction.fromJson(json), throwsFormatException);
});
});$ dart test test/models/transaction_test.dart -n JSON
00:00 +3: All tests passed!fromJson throws immediately, so the call goes inside a closure (Lecture 121). The round
trip also checks that an Expense comes back as an Expense.
7. Testing Exceptions in Services
Service tests share mutable state — the app — so each test gets a fresh one in setUp,
and tearDown closes it:
test/services/ledger_service_test.dart (lines 1–15, then 40–52)
import 'package:budget_tracker/budget_tracker.dart';
import 'package:test/test.dart';
import '../helpers/test_app.dart';
void main() {
late BudgetApp app;
late LedgerService ledger;
setUp(() async {
app = await seededApp();
ledger = app.ledger;
});
tearDown(() => app.close()); group('refuses', () {
test('spending more than the balance', () {
expect(
ledger.recordExpense('sara', const Money(999999), Category.fun),
throwsA(
isA<InsufficientFundsException>().having(
(e) => e.balance,
'balance',
const Money(200000),
),
),
);
});recordExpense returns a Future, so no closure is needed. Passing a Future with
throwsA to expect keeps the test running until the Future finishes — fine when the
check is the last line. When code follows the check, use await expectLater(...) from
Lecture 121. having proves the exception carries Sara's real balance, Rs 2000.00.
8. Comparing Records
BudgetService is tested with a month of known spending, set up before every test:
test/services/budget_service_test.dart (lines 6–45)
void main() {
late BudgetApp app;
setUp(() async {
app = await seededApp();
final ledger = app.ledger;
await ledger.recordExpense('ali', const Money(120000), Category.food);
await ledger.recordExpense('sara', const Money(30000), Category.food);
await ledger.recordExpense('sara', const Money(80000), Category.books);
await ledger.recordExpense(
'ali',
const Money(9900),
Category.fun,
on: DateTime(2026, 9, 30),
);
await app.budgets.setBudget(Category.food, const Money(150000));
await app.budgets.setBudget(Category.rent, const Money(400000));
});
tearDown(() => app.close());
test('the report adds up only the chosen month', () async {
final report = await app.budgets.monthlyReport(testToday);
expect(report.income, const Money(500000));
expect(report.spent, const Money(230000));
expect(report.net, const Money(270000));
});
test('lines cover spent and budgeted categories in enum order', () async {
final report = await app.budgets.monthlyReport(testToday);
expect(report.lines, [
(
category: Category.food,
spent: const Money(150000),
limit: const Money(150000),
),
(category: Category.books, spent: const Money(80000), limit: null),
(category: Category.rent, spent: Money.zero, limit: const Money(400000)),
]);
});CategoryLine is a record, and records with equal fields are equal (Lecture 076), so one
expect checks the whole list. Rent appears with nothing spent because it has a budget.
9. Testing Files in a Temporary Folder
JsonFileRepository really writes files. Each test gets its own empty folder from the
operating system, deleted afterwards:
test/repository/json_file_repository_test.dart
import 'dart:io';
import 'package:budget_tracker/budget_tracker.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
late Directory dir;
late File file;
JsonFileRepository<Wallet, String> repository() =>
JsonFileRepository(file, Wallet.fromJson);
setUp(() async {
dir = await Directory.systemTemp.createTemp('budget_test_');
file = File(p.join(dir.path, 'wallets.json'));
});
tearDown(() => dir.delete(recursive: true));
test('a missing file means an empty repository', () async {
expect(await repository().findAll(), isEmpty);
});
test('saved items can be read by a new repository', () async {
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Cash');
await repository().save(ali);
final reopened = repository();
expect(await reopened.findById('ali'), ali);
expect(await reopened.count(), 1);
});
test('saving the same id replaces the item', () async {
final repo = repository();
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Cash');
await repo.save(ali);
await repo.save(ali.copyWith(name: 'Bank card'));
expect((await repository().findById('ali'))?.name, 'Bank card');
});
test('delete removes the item from the file', () async {
final repo = repository();
await repo.save(const Wallet(id: 'ali', owner: 'Ali', name: 'Cash'));
await repo.delete('ali');
expect(await repository().exists('ali'), isFalse);
});
test('a broken file is reported as a StorageException', () async {
await file.writeAsString('{not json');
expect(repository().findAll(), throwsA(isA<StorageException>()));
});
}$ dart test test/repository
00:00 +5: All tests passed!The repository caches what it read, so the tests read back through a new repository — that is the only way to prove the data reached the file.
10. Testing the Event Stream
The ledger's events stream is broadcast, and broadcast streams do not keep events for late
listeners. So the order is: start listening, act, close, then await the check.
test/services/ledger_service_test.dart (lines 93–110)
test('every change is announced on the events stream', () async {
final events = expectLater(
ledger.events,
emitsInOrder([
isA<WalletOpened>(),
isA<TransactionRecorded>().having(
(e) => e.transaction,
'transaction',
isA<Expense>(),
),
emitsDone,
]),
);
await ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await ledger.recordExpense('ali', const Money(100), Category.fun);
await ledger.close();
await events;
});expectLater subscribes at once and returns a Future that we await only at the end.
emitsDone checks that close() really ends the stream — the same shutdown bin/ relies on.
11. A Real-World Example
The monitor test combines everything: the helper, the fake clock, records, an async*
generator and the stream order.
test/services/budget_monitor_test.dart
import 'package:budget_tracker/budget_tracker.dart';
import 'package:test/test.dart';
import '../helpers/test_app.dart';
void main() {
test('alerts when an expense passes 80% and then 100% of a budget', () async {
final app = await seededApp();
await app.budgets.setBudget(Category.food, const Money(100000));
final alerts = expectLater(
app.monitor.watch().map((a) => (a.category, a.level)),
emitsInOrder([
(Category.food, BudgetLevel.warning),
(Category.food, BudgetLevel.over),
emitsDone,
]),
);
await app.ledger.recordExpense('ali', const Money(50000), Category.food);
await app.ledger.recordExpense('ali', const Money(30000), Category.food);
await app.ledger.recordExpense('ali', const Money(30000), Category.food);
await app.ledger.recordExpense('ali', const Money(90000), Category.books);
await app.close();
await alerts;
});
}Rs 500 of Rs 1000 is fine (no alert), Rs 800 is a warning, Rs 1100 is over, and the books expense has no budget, so it adds nothing. Mapping each alert to a record keeps the expected list short. Now run the whole suite and the analyzer:
$ dart test
00:00 +40: All tests passed!
$ dart analyze
Analyzing budget_tracker...
No issues found!dart test shows one updating line; add -r expanded -j 1 to list every test name, one
file at a time. The app itself is unchanged: dart run budget_tracker demo behaves exactly
as in Lecture 137.
What the suite cannot see. All 40 tests pass, yet bin/budget_tracker.dart has no tests
at all — its argument parsing lives inside one long main, which a test cannot call. That
gap is where Lecture 139 begins.
Code Examples
Examples 1–10 are the remaining tests of the stage — add each to the file named above it.
Run one with -n; the output shown is the last line.
Example 1 — copyWith Returns a New Wallet
test/models/wallet_budget_test.dart (lines 1–12)
import 'package:budget_tracker/budget_tracker.dart';
import 'package:test/test.dart';
void main() {
const wallet = Wallet(id: 'ali', owner: 'Ali', name: 'Cash');
test('Wallet.copyWith keeps the id and returns a new object', () {
final renamed = wallet.copyWith(name: 'Bank card');
expect(renamed.id, 'ali');
expect(renamed.name, 'Bank card');
expect(wallet.name, 'Cash');
});$ dart test test/models/wallet_budget_test.dart -n copyWith
00:00 +1: All tests passed!The last line checks the original did not change (Lecture 124).
Example 2 — JSON Round Trips
test/models/wallet_budget_test.dart (lines 14–22)
test('Wallet JSON round trip gives an equal wallet', () {
expect(Wallet.fromJson(wallet.toJson()), wallet);
});
test('Budget is identified by its category', () {
const budget = Budget(category: Category.food, monthlyLimit: Money(1000));
expect(budget.id, Category.food);
expect(Budget.fromJson(budget.toJson()).monthlyLimit, const Money(1000));
});$ dart test test/models/wallet_budget_test.dart -n "round trip|identified"
00:00 +2: All tests passed!Example 3 — Parsing a Category
test/models/wallet_budget_test.dart (lines 24–27)
test('Category.tryParse ignores case and rejects unknown names', () {
expect(Category.tryParse('BOOKS'), Category.books);
expect(Category.tryParse('games'), isNull);
});$ dart test test/models/wallet_budget_test.dart -n tryParse
00:00 +1: All tests passed!Example 4 — A Transfer from Three Points of View
test/models/transaction_test.dart (lines 29–40)
group('effectOn', () {
test('income adds and expense subtracts', () {
expect(income.effectOn('ali'), const Money(5000));
expect(expense.effectOn('ali'), const Money(-1200));
});
test('a transfer moves money from one wallet to the other', () {
expect(transfer.effectOn('ali'), const Money(-700));
expect(transfer.effectOn('sara'), const Money(700));
expect(transfer.effectOn('zayan'), Money.zero);
});
});$ dart test test/models/transaction_test.dart -n effectOn
00:00 +2: All tests passed!Example 5 — copyWith on an Expense
test/models/transaction_test.dart (lines 62–69)
test('Expense.copyWith changes only the given fields', () {
final fixed = expense.copyWith(category: Category.fun);
expect(fixed.category, Category.fun);
expect(fixed.amount, expense.amount);
expect(fixed.id, expense.id);
expect(expense.category, Category.food);
});
}$ dart test test/models/transaction_test.dart -n Expense.copyWith
00:00 +1: All tests passed!Example 6 — Generic Extensions
test/extensions/iterable_extensions_test.dart
import 'package:budget_tracker/budget_tracker.dart';
import 'package:test/test.dart';
void main() {
test('groupBy puts items with the same key together', () {
final groups = ['Ali', 'Sara', 'Asad', 'Zayan'].groupBy((n) => n[0]);
expect(groups, {
'A': ['Ali', 'Asad'],
'S': ['Sara'],
'Z': ['Zayan'],
});
});
test('sumMoney adds the chosen amount of every item', () {
final prices = {'pen': 50, 'book': 450};
final total = prices.values.sumMoney((rupees) => Money(rupees * 100));
expect(total, const Money(50000));
expect(<int>[].sumMoney((n) => Money(n)), Money.zero);
});
}$ dart test test/extensions/iterable_extensions_test.dart
00:00 +2: All tests passed!Generic extensions are tested with plain strings and a shopping list.
Example 7 — Bank Balance after Spending and a Transfer
test/services/ledger_service_test.dart (lines 17–23)
test('balance is the opening balance plus every transaction', () async {
await ledger.recordExpense('ali', const Money(120000), Category.food);
await ledger.transfer('ali', 'sara', const Money(50000));
expect(await ledger.balanceOf('ali'), const Money(330000));
expect(await ledger.balanceOf('sara'), const Money(250000));
});$ dart test test/services/ledger_service_test.dart -n "opening balance"
00:00 +1: All tests passed!Ali: 5000 − 1200 − 500 = 3300. Sara: 2000 + 500 = 2500.
Example 8 — The Fake Clock
test/services/ledger_service_test.dart (lines 30–38)
test('new transactions get the next id and the clock date', () async {
final expense = await ledger.recordExpense(
'sara',
const Money(100),
Category.books,
);
expect(expense.id, 't0002');
expect(expense.date, testToday);
});$ dart test test/services/ledger_service_test.dart -n "clock date"
00:00 +1: All tests passed!Example 9 — A Monthly Report for One Wallet
test/services/budget_service_test.dart (lines 55–59)
test('a wallet filter keeps only that wallet', () async {
final report = await app.budgets.monthlyReport(testToday, walletId: 'sara');
expect(report.income, Money.zero);
expect(report.spent, const Money(110000));
});$ dart test test/services/budget_service_test.dart -n "chosen month|wallet filter"
00:00 +2: All tests passed!Sara spent Rs 300 on food and Rs 800 on books; the allowance is Ali's.
Example 10 — The Biggest Expenses
test/services/budget_service_test.dart (lines 47–53)
test('top expenses are the largest first', () async {
final report = await app.budgets.monthlyReport(testToday, top: 2);
expect(
[for (final e in report.topExpenses) e.amount],
[const Money(120000), const Money(80000)],
);
});$ dart test test/services/budget_service_test.dart -n "top expenses"
00:00 +1: All tests passed!Common Beginner Mistakes
1. Acting before listening. Recording events first and subscribing afterwards:
await app.ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await app.ledger.close();
await expectLater(
app.ledger.events,
emitsInOrder([isA<WalletOpened>(), emitsDone]),
); Expected: should do the following in order:
• emit an event that <Instance of 'WalletOpened'>
• be done
Actual: <Instance of '_BroadcastStream<LedgerEvent>'>
Which: emitted x Stream closed.
which didn't emit an event that <Instance of 'WalletOpened'>A broadcast stream does not replay events. Call expectLater first, await it last.
2. Forgetting await app.close() in a stream test. Without it the stream never ends, so
emitsDone waits forever:
00:30 +0 -1: alerts without closing [E]
TimeoutException after 0:00:30.000000: Test timed out after 30 seconds. See https://pub.dev/packages/test#timeouts3. One app for all tests. Changing setUp/tearDown in ledger_service_test.dart to
setUpAll/tearDownAll makes three tests fail, because earlier tests already added
transactions (first failure shown):
00:00 +2 -1: new transactions get the next id and the clock date [E]
Expected: 't0002'
Actual: 't0004'
Which: is different.
Expected: t0002
Actual: t0004
^
Differ at offset 4Best Practices
- Mirror
lib/src/intest/; keep shared builders intest/helpers/. - Create a fresh app in
setUp; share only values that never change. - Inject the clock and the storage, then test with fakes and in-memory repositories.
- Test both sides of every boundary — table-driven tests make that cheap.
- Break the code on purpose once to see each new test go red.
- For streams: subscribe, act, close, then await the check.
- Read back through a new repository to prove data reached the file.
Mini Challenge
Add a test to budget_service_test.dart that checks the September report: no income,
Rs 99.00 spent, and lines for food, rent and fun in that order.
Solution
test('September holds only the fun expense', () async {
final report = await app.budgets.monthlyReport(DateTime(2026, 9, 1));
expect(report.income, Money.zero);
expect(report.spent, const Money(9900));
expect(
[for (final line in report.lines) line.category],
[Category.food, Category.rent, Category.fun],
);
});$ dart test test/services/budget_service_test.dart -n September
00:00 +1: All tests passed!Food and rent have budgets; enum order puts them before fun.
Practice Questions
Questions 2, 3 and 5 complete the stage files; the rest are optional extras.
- In
money_test.dart, test thatformat()givesRs 0.05for 5 paisa,Rs 1000000.00for a million rupees and-Rs 0.99for −99 paisa. - In
ledger_service_test.dart, write balances lists the richest wallet first: the wallet ids in order must beali,sara. - Complete the
refusesgroup with an unknown wallet (income forbilal), a zero amount (a transfer ofMoney.zero) and a duplicate wallet id (aliagain). - Zayan opens a wallet with Rs 300 and tries to spend Rs 500 on fun. Check the exception's
walletIdandrequestedwith twohavingcalls, then check his balance is unchanged. - Write history shows only the wallet's transactions, oldest first: an expense for Ali on
2 October and one for Sara on 1 October; Ali's history ids must be
t0002,t0001. - Predict what
monthlyReport(DateTime(2026, 11, 1))returns with thebudget_service_test.dartsetup, then write a test that checksspentandlines. - Test that a transfer appears on
ledger.eventsas oneTransactionRecordedholding aTransfer, followed by the end of the stream. - In
json_file_repository_test.dart, save anExpensetotransactions.jsonin the temp folder and prove that a new repository reads it back as anExpensewith its category. - In
budget_monitor_test.dart, show that a Rs 4500 books expense with no books budget produces no alert. - Challenge:
dart run budget_tracker spend ali 200 food pizza with friendssaves onlypizzaas the note. Write a test that catches this bug. What do you find?
Practice Answers
Question 1 — Solution
test('format handles paisa only and big amounts', () {
expect(const Money(5).format(), 'Rs 0.05');
expect(const Money(100000000).format(), 'Rs 1000000.00');
expect(const Money(-99).format(), '-Rs 0.99');
});$ dart test test/models/money_test.dart
00:00 +5: All tests passed!Question 2 — Solution
test('balances lists the richest wallet first', () async {
final balances = await ledger.balances();
expect([for (final b in balances) b.wallet.id], ['ali', 'sara']);
});00:00 +1: All tests passed!Ali holds Rs 5000, Sara Rs 2000.
Question 3 — Solution
test('an unknown wallet', () {
expect(
ledger.recordIncome('bilal', const Money(100), source: 'Gift'),
throwsA(isA<WalletNotFoundException>()),
);
});
test('a zero amount', () {
expect(
ledger.transfer('ali', 'sara', Money.zero),
throwsA(isA<InvalidAmountException>()),
);
});
test('a duplicate wallet id', () {
expect(
ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Again'),
throwsA(isA<DuplicateWalletException>()),
);
});
});$ dart test test/services/ledger_service_test.dart -n refuses
00:00 +4: All tests passed!Question 4 — Solution
test('Zayan cannot spend more than his cash', () async {
await ledger.openWallet(
id: 'zayan',
owner: 'Zayan',
name: 'Cash',
openingBalance: const Money(30000),
);
await expectLater(
ledger.recordExpense('zayan', const Money(50000), Category.fun),
throwsA(
isA<InsufficientFundsException>()
.having((e) => e.walletId, 'walletId', 'zayan')
.having((e) => e.requested, 'requested', const Money(50000)),
),
);
expect(await ledger.balanceOf('zayan'), const Money(30000));
});00:00 +1: All tests passed!A line follows the check, so this time it is await expectLater.
Question 5 — Solution
test('history shows only the wallet\'s transactions, oldest first', () async {
await ledger.recordExpense(
'ali',
const Money(100),
Category.food,
on: DateTime(2026, 10, 2),
);
await ledger.recordExpense(
'sara',
const Money(100),
Category.food,
on: DateTime(2026, 10, 1),
);
final history = await ledger.historyOf('ali');
expect([for (final t in history) t.id], ['t0002', 't0001']);
});00:00 +1: All tests passed!The allowance t0001 is dated 15 October, so the 2 October expense comes first.
Question 6 — Solution
No spending, but food and rent still appear because they have budgets:
test('November has no spending, only budget lines', () async {
final report = await app.budgets.monthlyReport(DateTime(2026, 11, 1));
expect(report.spent, Money.zero);
expect(report.lines, [
(category: Category.food, spent: Money.zero, limit: const Money(150000)),
(category: Category.rent, spent: Money.zero, limit: const Money(400000)),
]);
});00:00 +1: All tests passed!Question 7 — Solution
test('a transfer is announced as one TransactionRecorded', () async {
final events = expectLater(
ledger.events,
emitsInOrder([
isA<TransactionRecorded>().having(
(e) => e.transaction,
'transaction',
isA<Transfer>(),
),
emitsDone,
]),
);
await ledger.transfer('ali', 'sara', const Money(10000));
await ledger.close();
await events;
});00:00 +1: All tests passed!Question 8 — Solution
test('a transactions file keeps the kind of each item', () async {
final transactions = File(p.join(dir.path, 'transactions.json'));
final expense = Expense(
id: 't0001',
walletId: 'zayan',
amount: const Money(60000),
date: DateTime(2026, 10, 6),
category: Category.transport,
);
await JsonFileRepository(transactions, Transaction.fromJson).save(expense);
final reopened = JsonFileRepository<Transaction, String>(
transactions,
Transaction.fromJson,
);
final copy = await reopened.findById('t0001');
expect(copy, isA<Expense>());
expect((copy as Expense).category, Category.transport);
});00:00 +1: All tests passed!Question 9 — Solution
test('no budget means no alert', () async {
final app = await seededApp();
final alerts = expectLater(app.monitor.watch(), emitsDone);
await app.ledger.recordExpense('ali', const Money(450000), Category.books);
await app.close();
await alerts;
});$ dart test test/services/budget_monitor_test.dart
00:00 +2: All tests passed!emitsDone alone means "ends without emitting anything".
Question 10 — Solution
The closest test we can write uses the service — and it passes:
test('a long note is kept whole', () async {
final expense = await ledger.recordExpense(
'ali',
const Money(20000),
Category.food,
note: 'pizza with friends',
);
expect(expense.note, 'pizza with friends');
});00:00 +1: All tests passed!The bug is in bin/budget_tracker.dart, which takes only args[4] as the note. That code
sits inside main, so no test can call it. Lecture 139 moves it into code a test can reach.
Quick Revision
- Why does
test/helpers/test_app.dartnot end in_test.dart? - What does the fake clock
() => testTodaymake possible? - Why is it safe to share the transactions in
transaction_test.dartbut not the app inledger_service_test.dart? - How do you prove a new test really checks something?
- When is
expect(future, throwsA(...))enough, and when do you needawait expectLater? - In a stream test, in what order do you subscribe, act, close and await?
- Which part of the project has no tests, and why?
Key Takeaways
- The suite mirrors
lib/src/: models, extensions, repository and services — 40 tests. - An injected clock, injected repositories and
BudgetApp.inMemory()made testing possible without changinglib/. - A helper like
seededApp()gives every test the same known starting point. - Map-driven tests cover every boundary; watching a test fail proves it works.
- Exceptions: a closure for synchronous code, the
Futureitself for async code,havingfor the fields. - Files are tested in a temporary folder, deleted in
tearDown. - Streams:
expectLaterfirst, then act,close(), and await the check last. - Code inside
maincannot be tested — the CLI is our blind spot.
What's Next?
Lecture 139 — Analysis & Refactoring. Our services now have a safety net, but bin/ does
not, and it hides three real bugs. Next we will switch on a stricter analyzer, let dart fix
do the mechanical work, and refactor the long main into small pieces that tests can reach —
with these 40 tests telling us we broke nothing.