Watch the video (10 min)
On this page24 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. The Project Layout
  5. 2. The Normal Case
  6. 3. Edge Cases
  7. 4. Boundaries
  8. 5. Testing a Class Through Its Public API
  9. 6. State Over Several Steps
  10. 7. Testing == and hashCode
  11. 8. Code That Is Hard to Test
  12. 9. Injecting a Dependency
  13. 10. A Hand-Written Fake
  14. 11. A Fake Repository
  15. 12. A Real-World Example
  16. Code Examples
  17. Common Beginner Mistakes
  18. Best Practices
  19. Mini Challenge
  20. Practice Questions
  21. Practice Answers
  22. Quick Revision
  23. Key Takeaways
  24. What's Next?

Lecture 120 — Testing Functions & Classes

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

Writing a test is easy. Knowing what to test is harder — and a class that reads the clock or a database seems untestable. Here you pick the cases that catch bugs and swap real dependencies for small fakes.

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 001–119:

  • getters and private _ members (Lectures 056–057)
  • overriding == and hashCode (Lecture 060)
  • abstract classes and implements (Lectures 062–063)
  • packages, lib/, package: imports and dev_dependencies (Lectures 102–105)
  • DateTime and difference (Lectures 110–111)
  • test, expect, matchers, group, and Arrange–Act–Assert (Lectures 118–119)

Connection With Previous Lectures

In Lecture 119 every test passed. But a test proves only what it checks: average([80, 70, 90]) says nothing about an empty list. And a greeting that uses DateTime.now() answers differently at 9 a.m. and 3 p.m. Which cases catch real bugs, and how do we test code that depends on things we cannot control?


Learning Objectives

By the end of this lecture you will be able to:

  • Organize code in lib/ and tests in test/ with package: imports
  • Choose edge cases: empty lists, zero, negative numbers, and boundaries
  • Test a class only through its public API
  • Check state changes over several steps
  • Test == and hashCode overrides
  • Inject a dependency through an interface instead of calling DateTime.now() inside a class
  • Write simple hand-made fakes

1. The Project Layout

Code lives in lib/; tests live in test/ and import it with package:. Our project is testing_demo:

output
testing_demo/
  pubspec.yaml        ← test in dev_dependencies
  lib/marks.dart
  test/marks_test.dart
dart
// lib/marks.dart
double average(List<int> marks) {
  if (marks.isEmpty) return 0;
  var total = 0;
  for (var mark in marks) {
    total += mark;
  }
  return total / marks.length;
}

String grade(int mark) {
  if (mark < 0 || mark > 100) return 'Invalid';
  if (mark >= 80) return 'A';
  if (mark >= 50) return 'B';
  return 'F';
}

Both are pure functions: the same input always gives the same output, and nothing else changes. They are the easiest code to test.


2. The Normal Case

Start with the case everyone thinks of:

dart
// test/marks_test.dart
import 'package:test/test.dart';
import 'package:testing_demo/marks.dart';

void main() {
  test('average of three marks', () {
    expect(average([80, 70, 90]), equals(80.0));
  });
}
output
00:00 +0: loading test/marks_test.dart
00:00 +0: test/marks_test.dart: average of three marks
00:00 +1: All tests passed!

Analogy — a car inspector doesn't only drive on a sunny, straight road. Bugs hide at the edges.


3. Edge Cases

An edge case is an unusual input: an empty list, a single item, zero, a negative number.

dart
test('of an empty list is 0', () {
  expect(average([]), equals(0));
});
test('of all zeros is 0', () {
  expect(average([0, 0, 0]), equals(0));
});

Without the isEmpty check, average([]) would compute 0 / 0 and return NaN. The test forces you to decide what an empty list gives.


4. Boundaries

Where code compares (>=, <), test the values on both sides of the line:

dart
test('boundary of A', () {
  expect(grade(80), equals('A'));
  expect(grade(79), equals('B'));
});

Suppose grade had mark > 80 by mistake. A test with 90 passes; only the boundary test catches it:

output
00:00 +1 -1: boundary of A [E]
  Expected: 'A'
    Actual: 'B'
     Which: is different.

5. Testing a Class Through Its Public API

Test a class the way other code uses it: through constructors, methods and getters.

dart
// lib/bank_account.dart
class BankAccount {
  final String owner;
  double _balance = 0;

  BankAccount(this.owner);

  double get balance => _balance;

  bool deposit(double amount) {
    if (amount <= 0) return false;
    _balance += amount;
    return true;
  }

  bool withdraw(double amount) {
    if (amount <= 0 || amount > _balance) return false;
    _balance -= amount;
    return true;
  }
}
dart
test('withdraw more than the balance is refused', () {
  var account = BankAccount('Ali');
  account.deposit(100);
  var ok = account.withdraw(150);
  expect(ok, isFalse);
  expect(account.balance, equals(100));
});

The test never touches _balance, so that field can change later without breaking it.

Here withdraw returns false. Code that throws an exception instead is tested in Lecture 121.


6. State Over Several Steps

Objects remember things. Test a sequence, checking after each step:

dart
test('balance after several steps', () {
  var account = BankAccount('Sara');
  account.deposit(1000);
  expect(account.balance, equals(1000));
  account.withdraw(300);
  expect(account.balance, equals(700));
  account.withdraw(800);
  expect(account.balance, equals(700));
  account.deposit(50.5);
  expect(account.balance, equals(750.5));
});
output
00:00 +1: All tests passed!

The failed withdrawal in the middle matters most: a refusal must leave the state untouched.


7. Testing == and hashCode

When you override == (Lecture 060), test equal values, different values, and the hash code:

dart
// lib/money.dart
class Money {
  final int amount;
  final String currency;

  Money(this.amount, this.currency);

  @override
  bool operator ==(Object other) =>
      other is Money && other.amount == amount && other.currency == currency;

  @override
  int get hashCode => Object.hash(amount, currency);
}
dart
test('equal values have equal hash codes', () {
  var a = Money(500, 'PKR');
  var b = Money(500, 'PKR');
  expect(a, equals(b));
  expect(a.hashCode, equals(b.hashCode));
});
test('a different currency is not equal', () {
  expect(Money(500, 'PKR'), isNot(equals(Money(500, 'USD'))));
});
test('a set keeps one copy of equal values', () {
  var wallet = {Money(500, 'PKR'), Money(500, 'PKR'), Money(100, 'PKR')};
  expect(wallet.length, equals(2));
});
output
00:00 +3: All tests passed!

8. Code That Is Hard to Test

This class reads the real clock itself:

dart
class HardGreeter {
  String greet(String name) {
    var hour = DateTime.now().hour;
    if (hour < 12) return 'Good morning, $name';
    return 'Good evening, $name';
  }
}

A test expecting 'Good morning, Ali' passes before noon and fails after it. Run in the afternoon:

output
00:00 +0 -1: greets in the morning [E]
  Expected: 'Good morning, Ali'
    Actual: 'Good evening, Ali'

A test whose result changes from run to run is flaky. The class is the problem, not the test.


9. Injecting a Dependency

Describe what the class needs as an interface (Lecture 063) and pass it in through the constructor. This is dependency injection.

dart
// lib/clock.dart
abstract class Clock {
  DateTime now();
}

class SystemClock implements Clock {
  @override
  DateTime now() => DateTime.now();
}
dart
// lib/greeter.dart
import 'clock.dart';

class Greeter {
  final Clock clock;

  Greeter(this.clock);

  String greet(String name) {
    var hour = clock.now().hour;
    if (hour < 12) return 'Good morning, $name';
    return 'Good evening, $name';
  }
}

The real program writes Greeter(SystemClock()); tests pass something else.


10. A Hand-Written Fake

A fake is a simple class, written in the test, that implements the same interface with predictable behaviour:

dart
class FixedClock implements Clock {
  final DateTime time;
  FixedClock(this.time);

  @override
  DateTime now() => time;
}

void main() {
  test('11:59 is still morning', () {
    var greeter = Greeter(FixedClock(DateTime(2026, 3, 1, 11, 59)));
    expect(greeter.greet('Sara'), equals('Good morning, Sara'));
  });

  test('12:00 is evening', () {
    var greeter = Greeter(FixedClock(DateTime(2026, 3, 1, 12, 0)));
    expect(greeter.greet('Sara'), equals('Good evening, Sara'));
  });
}
output
00:00 +2: All tests passed!

Now the boundary at noon can be tested at any time of day.


11. A Fake Repository

The same trick replaces a database or a file. This fake keeps its data in a map:

dart
// lib/report.dart
import 'marks.dart';

abstract class MarksRepository {
  List<int> marksOf(String student);
}

class ReportCard {
  final MarksRepository repository;

  ReportCard(this.repository);

  String summary(String student) {
    var marks = repository.marksOf(student);
    if (marks.isEmpty) return '$student: no marks yet';
    var avg = average(marks);
    return '$student: ${avg.toStringAsFixed(1)} (${grade(avg.round())})';
  }
}
dart
class FakeMarksRepository implements MarksRepository {
  final Map<String, List<int>> data;
  FakeMarksRepository(this.data);

  @override
  List<int> marksOf(String student) => data[student] ?? [];
}

void main() {
  var card = ReportCard(
    FakeMarksRepository({
      'Ali': [80, 90, 85],
    }),
  );

  test('summary for a strong student', () {
    expect(card.summary('Ali'), equals('Ali: 85.0 (A)'));
  });
  test('a student with no marks', () {
    expect(card.summary('Zayan'), equals('Zayan: no marks yet'));
  });
}
output
00:00 +2: All tests passed!

A fake can also record the calls it receives — you will write one in Question 10. Packages such as mockito and mocktail can generate these test doubles for you. For most classes, a ten-line fake is clearer.


12. A Real-World Example

A library loan charges 20 rupees per day late. With an injected clock, the tests can stand on the due date and the days after it:

dart
// lib/loan.dart
import 'clock.dart';

class Loan {
  final String book;
  final DateTime dueDate;
  final Clock clock;

  Loan(this.book, this.dueDate, this.clock);

  int daysLate() {
    var days = clock.now().difference(dueDate).inDays;
    return days > 0 ? days : 0;
  }

  int lateFee() => daysLate() * 20;
}
dart
// test/loan_test.dart (FixedClock as in section 10)
Loan loanCheckedOn(DateTime today) =>
    Loan('Dart Basics', DateTime.utc(2026, 3, 10), FixedClock(today));

void main() {
  group('late fee', () {
    test('is 0 on the due date', () {
      expect(loanCheckedOn(DateTime.utc(2026, 3, 10)).lateFee(), equals(0));
    });
    test('is 20 one day late', () {
      expect(loanCheckedOn(DateTime.utc(2026, 3, 11)).lateFee(), equals(20));
    });
    test('grows with each day', () {
      var loan = loanCheckedOn(DateTime.utc(2026, 3, 20));
      expect(loan.lateFee(), equals(200));
    });
  });
}
output
00:00 +3: All tests passed!

Code Examples

Each example adds a lib/ file and a test file to testing_demo. Every test file starts with the two imports from section 2.

Example 1 — Normal and Negative Inputs

dart
// lib/temperature.dart
double toFahrenheit(double celsius) => celsius * 9 / 5 + 32;
dart
import 'package:test/test.dart';
import 'package:testing_demo/temperature.dart';

void main() {
  test('25 °C is 77 °F', () => expect(toFahrenheit(25), equals(77.0)));
  test('-40 is the same on both scales', () {
    expect(toFahrenheit(-40), equals(-40.0));
  });
}
output
00:00 +2: All tests passed!

Example 2 — An Empty List

dart
// lib/shopping.dart
int total(List<int> prices) {
  var sum = 0;
  for (var price in prices) {
    sum += price;
  }
  return sum;
}
dart
test('total of several prices', () {
  expect(total([500, 250, 100]), equals(850));
});
test('total of an empty cart is 0', () => expect(total([]), equals(0)));
output
00:00 +2: All tests passed!

Example 3 — A Boundary

dart
// lib/passing.dart
bool isPassing(int mark) => mark >= 50;
dart
test('50 passes', () => expect(isPassing(50), isTrue));
test('49 fails', () => expect(isPassing(49), isFalse));
output
00:00 +2: All tests passed!

Example 4 — Out-of-Range Inputs

dart
// lib/discount.dart
double discounted(double price, int percent) {
  if (percent <= 0) return price;
  if (percent >= 100) return 0;
  return price - price * percent / 100;
}
dart
test('20% off 1500', () => expect(discounted(1500, 20), equals(1200.0)));
test('0% changes nothing', () => expect(discounted(1500, 0), equals(1500.0)));
test('150% is still free', () => expect(discounted(1500, 150), equals(0)));
output
00:00 +3: All tests passed!

Example 5 — Initial State and Reset

dart
// lib/score_board.dart
class ScoreBoard {
  int _score = 0;

  int get score => _score;

  void add(int points) {
    if (points > 0) _score += points;
  }

  void reset() => _score = 0;
}
dart
test('add, add, reset', () {
  var board = ScoreBoard();
  expect(board.score, equals(0));
  board.add(10);
  board.add(5);
  expect(board.score, equals(15));
  board.reset();
  expect(board.score, equals(0));
});
output
00:00 +1: All tests passed!

Example 6 — Equality in a List

Uses Money from section 7.

dart
test('a list finds an equal value', () {
  var wallet = [Money(500, 'PKR'), Money(100, 'USD')];
  expect(wallet, contains(Money(100, 'USD')));
});
output
00:00 +1: All tests passed!

contains uses ==, so it only works because of the override.

Example 7 — Student Marks on a Boundary

Uses ReportCard and FakeMarksRepository from section 11.

dart
test('the average is rounded before grading', () {
  var card = ReportCard(
    FakeMarksRepository({
      'Sara': [45, 50],
    }),
  );
  expect(card.summary('Sara'), equals('Sara: 47.5 (F)'));
});
output
00:00 +1: All tests passed!

47.5 rounds to 48, which is below the 50 boundary.

Example 8 — A Shopping Cart Over Several Steps

dart
// lib/cart.dart
class Cart {
  final Map<String, int> _items = {};

  void add(String item, int price) => _items[item] = price;
  void remove(String item) => _items.remove(item);

  int get itemCount => _items.length;
  int get total => _items.values.fold(0, (sum, price) => sum + price);
}
dart
test('a cart through a whole shopping trip', () {
  var cart = Cart();
  expect(cart.total, equals(0));

  cart.add('Notebook', 500);
  cart.add('Pen', 50);
  expect(cart.itemCount, equals(2));
  expect(cart.total, equals(550));

  cart.remove('Pen');
  expect(cart.itemCount, equals(1));
  expect(cart.total, equals(500));
});
output
00:00 +1: All tests passed!

Example 9 — Bank Transfer Between Two Accounts

dart
// lib/transfer.dart
import 'bank_account.dart';

bool transfer(BankAccount from, BankAccount to, double amount) {
  if (!from.withdraw(amount)) return false;
  to.deposit(amount);
  return true;
}
dart
test('a transfer moves money between accounts', () {
  var ali = BankAccount('Ali')..deposit(1000);
  var sara = BankAccount('Sara');
  expect(transfer(ali, sara, 400), isTrue);
  expect(ali.balance, equals(600));
  expect(sara.balance, equals(400));
});
output
00:00 +1: All tests passed!

Example 10 — A Thermostat with a Fake Sensor

dart
// lib/thermostat.dart
abstract class TemperatureSensor {
  double read();
}

class Thermostat {
  final TemperatureSensor sensor;
  final double target;

  Thermostat(this.sensor, {this.target = 22});

  bool get heaterOn => sensor.read() < target;
}
dart
class FakeSensor implements TemperatureSensor {
  double value;
  FakeSensor(this.value);

  @override
  double read() => value;
}

void main() {
  test('heater follows the temperature', () {
    var sensor = FakeSensor(18.5);
    var thermostat = Thermostat(sensor);
    expect(thermostat.heaterOn, isTrue);

    sensor.value = 22;
    expect(thermostat.heaterOn, isFalse);
  });
}
output
00:00 +1: All tests passed!

The fake's value can change mid-test, so one test checks both sides of the target.


Common Beginner Mistakes

1. Reaching for private fields

dart
// expect(account._balance, equals(0)); // ✗
output
Error: The getter '_balance' isn't defined for the type 'BankAccount'.

Private members are invisible outside their library. Test balance.

2. Overriding == without hashCode

The analyzer warns Missing a corresponding override of 'hashCode'., and a set test fails:

output
  Expected: <2>
    Actual: <3>

3. Testing only the normal case

grade(90) passed with the > 80 bug. Always add the boundary values.

4. Calling DateTime.now() inside the class

The test result then depends on when it runs. Inject a Clock.


Best Practices

  • For every function, test a normal case, the edge cases, and both sides of each boundary.
  • Test behaviour through the public API, never private details.
  • Pass in anything that varies — time, data, sensors — through an interface.
  • Keep fakes small and inside the test file.

Mini Challenge

A shop is open from 9:00 until 20:59. Write Shop with an injected Clock and an isOpen getter, and test the four boundary minutes: 8:59, 9:00, 20:59, 21:00.

Try it yourself before opening the solution.

Show solution
dart
// lib/shop.dart
import 'clock.dart';

class Shop {
  final Clock clock;

  Shop(this.clock);

  bool get isOpen {
    var hour = clock.now().hour;
    return hour >= 9 && hour < 21;
  }
}
dart
// test/shop_test.dart (FixedClock as in section 10)
bool openAt(int hour, int minute) =>
    Shop(FixedClock(DateTime(2026, 5, 4, hour, minute))).isOpen;

void main() {
  test('closed at 8:59', () => expect(openAt(8, 59), isFalse));
  test('open at 9:00', () => expect(openAt(9, 0), isTrue));
  test('open at 20:59', () => expect(openAt(20, 59), isTrue));
  test('closed at 21:00', () => expect(openAt(21, 0), isFalse));
}
output
00:00 +4: All tests passed!

Practice Questions

Beginner

  1. Write int square(int n) and test it with 4, 0 and -3.
  2. Name three edge cases worth testing for int highest(List<int> numbers).
  3. bool isAdult(int age) => age >= 18; — which two values test the boundary? Write the tests.

Application

  1. Write a Counter with increment() and a decrement() that never goes below 0. Test: up twice, down three times, value 0.
  2. Write Student(name, roll) with == and hashCode. Test equality and hash codes.
  3. Zayan deposits 200, withdraws 50, then tries to withdraw 500. Test that the last withdrawal is refused and the balance is 150.
  4. Explain why the HardGreeter test in section 8 sometimes passes and sometimes fails.

Challenge

  1. Write a Coupon with an expiry date and an isExpired getter that uses an injected Clock. Test that it is expired on 1 July 2026 when it ends on 30 June.
  2. Write an Advisor that depends on WeatherService { double todayCelsius(); } and returns 'Take a jacket' below 15 degrees, else 'No jacket'. Test a 10-degree day with a fake.
  3. Write LowBalanceAlert that calls Notifier.send(message) when a balance is below 100. Use a recording fake to test that a balance of 80 sends exactly one message.

Practice Answers

Test files start with the same two kinds of import as section 2.

Question 1 — Solution

dart
// lib/square.dart
int square(int n) => n * n;
dart
test('square of 4', () => expect(square(4), equals(16)));
test('square of 0', () => expect(square(0), equals(0)));
test('square of -3', () => expect(square(-3), equals(9)));
output
00:00 +3: All tests passed!

Question 2 — Solution

An empty list (what should it return?), a list with one number, and a list of only negative numbers — a common bug starts the maximum at 0.

Question 3 — Solution

17 and 18, one on each side of the line.

dart
test('17 is not an adult', () => expect(isAdult(17), isFalse));
test('18 is an adult', () => expect(isAdult(18), isTrue));
output
00:00 +2: All tests passed!

Question 4 — Solution

dart
// lib/counter.dart
class Counter {
  int _value = 0;

  int get value => _value;

  void increment() => _value++;

  void decrement() {
    if (_value > 0) _value--;
  }
}
dart
test('up twice, down three times', () {
  var counter = Counter();
  counter.increment();
  counter.increment();
  expect(counter.value, equals(2));
  counter.decrement();
  counter.decrement();
  counter.decrement();
  expect(counter.value, equals(0));
});
output
00:00 +1: All tests passed!

Question 5 — Solution

dart
// lib/student.dart
class Student {
  final String name;
  final int roll;

  Student(this.name, this.roll);

  @override
  bool operator ==(Object other) =>
      other is Student && other.name == name && other.roll == roll;

  @override
  int get hashCode => Object.hash(name, roll);
}
dart
test('equal students', () {
  expect(Student('Ali', 7), equals(Student('Ali', 7)));
  expect(Student('Ali', 7).hashCode, equals(Student('Ali', 7).hashCode));
});
output
00:00 +1: All tests passed!

Question 6 — Solution

dart
test('Zayan saves and spends', () {
  var account = BankAccount('Zayan');
  account.deposit(200);
  account.withdraw(50);
  expect(account.withdraw(500), isFalse);
  expect(account.balance, equals(150));
});
output
00:00 +1: All tests passed!

Question 7 — Solution

HardGreeter reads DateTime.now() itself, so its answer depends on when the test runs: morning passes, afternoon fails. The test cannot control the input.

Question 8 — Solution

dart
// lib/coupon.dart
import 'clock.dart';

class Coupon {
  final DateTime expiresAt;
  final Clock clock;

  Coupon(this.expiresAt, this.clock);

  bool get isExpired => clock.now().isAfter(expiresAt);
}
dart
// FixedClock as in section 10
var expiry = DateTime(2026, 6, 30);

test('expired after expiry', () {
  var coupon = Coupon(expiry, FixedClock(DateTime(2026, 7, 1)));
  expect(coupon.isExpired, isTrue);
});
output
00:00 +1: All tests passed!

Question 9 — Solution

dart
// lib/weather.dart
abstract class WeatherService {
  double todayCelsius();
}

class Advisor {
  final WeatherService weather;

  Advisor(this.weather);

  String advice() =>
      weather.todayCelsius() < 15 ? 'Take a jacket' : 'No jacket';
}
dart
class FakeWeather implements WeatherService {
  final double celsius;
  FakeWeather(this.celsius);

  @override
  double todayCelsius() => celsius;
}

void main() {
  test('cold day', () {
    expect(Advisor(FakeWeather(10)).advice(), equals('Take a jacket'));
  });
}
output
00:00 +1: All tests passed!

Question 10 — Solution

dart
// lib/alert.dart
import 'bank_account.dart';

abstract class Notifier {
  void send(String message);
}

class LowBalanceAlert {
  final Notifier notifier;

  LowBalanceAlert(this.notifier);

  void check(BankAccount account) {
    if (account.balance < 100) {
      notifier.send('${account.owner}: balance is low');
    }
  }
}
dart
class FakeNotifier implements Notifier {
  final List<String> sent = [];

  @override
  void send(String message) => sent.add(message);
}

void main() {
  test('sends one message when the balance is low', () {
    var notifier = FakeNotifier();
    var account = BankAccount('Sara')..deposit(80);
    LowBalanceAlert(notifier).check(account);
    expect(notifier.sent, equals(['Sara: balance is low']));
  });
}
output
00:00 +1: All tests passed!

Quick Revision

  1. What makes a function pure, and why is it easy to test?
  2. Name four kinds of edge case.
  3. Why should a test use balance and not _balance?
  4. Why is DateTime.now() inside a class a problem for tests?
  5. What is a fake?

Key Takeaways

  • Code lives in lib/; tests live in test/ and use package: imports.
  • Test the normal case, edge cases (empty, zero, negative) and both sides of every boundary.
  • Test classes through their public API; private details may change.
  • Check state after every step, including steps that should change nothing.
  • Test == with equal and different values, plus hash codes and set behaviour.
  • DateTime.now() or a real database inside a class makes tests flaky or slow.
  • Inject dependencies through an interface; pass a hand-written fake in tests.

What's Next?

Lecture 121 — Testing Exceptions & Async Code. Our withdraw returned false when it refused, but much real code throws an exception or returns a Future. Next we will test that code throws the right error, and write tests that wait for asynchronous results.


Video lecture

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