Watch the video (20 min)
On this page23 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. Plan the Suite
  5. 2. A Helper That Builds a Test App
  6. 3. Testing a Model
  7. 4. Tests Generated from a Map
  8. 5. Red → Green: Watch a Test Fail
  9. 6. Testing Exceptions in Plain Code
  10. 7. Testing Exceptions in Services
  11. 8. Comparing Records
  12. 9. Testing Files in a Temporary Folder
  13. 10. Testing the Event Stream
  14. 11. A Real-World Example
  15. Code Examples
  16. Common Beginner Mistakes
  17. Best Practices
  18. Mini Challenge
  19. Practice Questions
  20. Practice Answers
  21. Quick Revision
  22. Key Takeaways
  23. What's Next?

Lecture 138 — Project Testing

Category23 — Final Dart Project
Lecture138 of 140
Estimated video length20 min
TargetDart 3.12.2
LevelAdvanced

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 test on 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 and group (Lectures 118–119)
  • testing classes, edge cases and fakes (Lecture 120)
  • throwsA, having, expectLater, emitsInOrder, emitsDone (Lecture 121)
  • setUp, tearDown, late, temporary folders and test/helpers/ (Lecture 122)
  • the budget tracker as built in Lectures 132–137: models, services, repositories, BudgetApp, the event stream and the BudgetMonitor

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 mirrors lib/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 with emitsInOrder and emitsDone
  • 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:

output
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.dart

Notice 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

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

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

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

output
$ 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)

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

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

dart
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());
dart
  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)

dart
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

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>()));
  });
}
output
$ 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)

dart
  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

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:

output
$ 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)

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

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

dart
  test('Category.tryParse ignores case and rejects unknown names', () {
    expect(Category.tryParse('BOOKS'), Category.books);
    expect(Category.tryParse('games'), isNull);
  });
output
$ 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)

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

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

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

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

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

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

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

dart
await app.ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
await app.ledger.close();
await expectLater(
  app.ledger.events,
  emitsInOrder([isA<WalletOpened>(), emitsDone]),
);
output
  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:

output
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#timeouts

3. 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):

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

Best Practices

  • Mirror lib/src/ in test/; keep shared builders in test/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

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

  1. In money_test.dart, test that format() gives Rs 0.05 for 5 paisa, Rs 1000000.00 for a million rupees and -Rs 0.99 for −99 paisa.
  2. In ledger_service_test.dart, write balances lists the richest wallet first: the wallet ids in order must be ali, sara.
  3. Complete the refuses group with an unknown wallet (income for bilal), a zero amount (a transfer of Money.zero) and a duplicate wallet id (ali again).
  4. Zayan opens a wallet with Rs 300 and tries to spend Rs 500 on fun. Check the exception's walletId and requested with two having calls, then check his balance is unchanged.
  5. 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.
  6. Predict what monthlyReport(DateTime(2026, 11, 1)) returns with the budget_service_test.dart setup, then write a test that checks spent and lines.
  7. Test that a transfer appears on ledger.events as one TransactionRecorded holding a Transfer, followed by the end of the stream.
  8. In json_file_repository_test.dart, save an Expense to transactions.json in the temp folder and prove that a new repository reads it back as an Expense with its category.
  9. In budget_monitor_test.dart, show that a Rs 4500 books expense with no books budget produces no alert.
  10. Challenge: dart run budget_tracker spend ali 200 food pizza with friends saves only pizza as the note. Write a test that catches this bug. What do you find?

Practice Answers

Question 1 — Solution

dart
  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');
  });
output
$ dart test test/models/money_test.dart
00:00 +5: All tests passed!

Question 2 — Solution

dart
  test('balances lists the richest wallet first', () async {
    final balances = await ledger.balances();
    expect([for (final b in balances) b.wallet.id], ['ali', 'sara']);
  });
output
00:00 +1: All tests passed!

Ali holds Rs 5000, Sara Rs 2000.

Question 3 — Solution

dart
    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>()),
      );
    });
  });
output
$ dart test test/services/ledger_service_test.dart -n refuses
00:00 +4: All tests passed!

Question 4 — Solution

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

A line follows the check, so this time it is await expectLater.

Question 5 — Solution

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

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

Question 7 — Solution

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

Question 8 — Solution

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

Question 9 — Solution

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

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

  1. Why does test/helpers/test_app.dart not end in _test.dart?
  2. What does the fake clock () => testToday make possible?
  3. Why is it safe to share the transactions in transaction_test.dart but not the app in ledger_service_test.dart?
  4. How do you prove a new test really checks something?
  5. When is expect(future, throwsA(...)) enough, and when do you need await expectLater?
  6. In a stream test, in what order do you subscribe, act, close and await?
  7. 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 changing lib/.
  • 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 Future itself for async code, having for the fields.
  • Files are tested in a temporary folder, deleted in tearDown.
  • Streams: expectLater first, then act, close(), and await the check last.
  • Code inside main cannot 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.


Video lecture

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