On this page24 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. The Project Layout
- 2. The Normal Case
- 3. Edge Cases
- 4. Boundaries
- 5. Testing a Class Through Its Public API
- 6. State Over Several Steps
- 7. Testing == and hashCode
- 8. Code That Is Hard to Test
- 9. Injecting a Dependency
- 10. A Hand-Written Fake
- 11. A Fake Repository
- 12. 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 120 — Testing Functions & Classes
| Category | 20 — Testing |
| Lecture | 120 of 140 |
| Estimated video length | 10 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
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
==andhashCode(Lecture 060) - abstract classes and
implements(Lectures 062–063) - packages,
lib/,package:imports anddev_dependencies(Lectures 102–105) DateTimeanddifference(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 intest/withpackage: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
==andhashCodeoverrides - 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:
testing_demo/
pubspec.yaml ← test in dev_dependencies
lib/marks.dart
test/marks_test.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:
// 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));
});
}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.
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:
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:
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.
// 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;
}
}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:
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));
});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:
// 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);
}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));
});00:00 +3: All tests passed!8. Code That Is Hard to Test
This class reads the real clock itself:
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:
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.
// lib/clock.dart
abstract class Clock {
DateTime now();
}
class SystemClock implements Clock {
@override
DateTime now() => DateTime.now();
}// 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:
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'));
});
}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:
// 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())})';
}
}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'));
});
}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:
// 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;
}// 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));
});
});
}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
// lib/temperature.dart
double toFahrenheit(double celsius) => celsius * 9 / 5 + 32;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));
});
}00:00 +2: All tests passed!Example 2 — An Empty List
// lib/shopping.dart
int total(List<int> prices) {
var sum = 0;
for (var price in prices) {
sum += price;
}
return sum;
}test('total of several prices', () {
expect(total([500, 250, 100]), equals(850));
});
test('total of an empty cart is 0', () => expect(total([]), equals(0)));00:00 +2: All tests passed!Example 3 — A Boundary
// lib/passing.dart
bool isPassing(int mark) => mark >= 50;test('50 passes', () => expect(isPassing(50), isTrue));
test('49 fails', () => expect(isPassing(49), isFalse));00:00 +2: All tests passed!Example 4 — Out-of-Range Inputs
// lib/discount.dart
double discounted(double price, int percent) {
if (percent <= 0) return price;
if (percent >= 100) return 0;
return price - price * percent / 100;
}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)));00:00 +3: All tests passed!Example 5 — Initial State and Reset
// 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;
}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));
});00:00 +1: All tests passed!Example 6 — Equality in a List
Uses Money from section 7.
test('a list finds an equal value', () {
var wallet = [Money(500, 'PKR'), Money(100, 'USD')];
expect(wallet, contains(Money(100, 'USD')));
});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.
test('the average is rounded before grading', () {
var card = ReportCard(
FakeMarksRepository({
'Sara': [45, 50],
}),
);
expect(card.summary('Sara'), equals('Sara: 47.5 (F)'));
});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
// 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);
}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));
});00:00 +1: All tests passed!Example 9 — Bank Transfer Between Two Accounts
// 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;
}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));
});00:00 +1: All tests passed!Example 10 — A Thermostat with a Fake Sensor
// 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;
}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);
});
}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
// expect(account._balance, equals(0)); // ✗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:
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
// 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;
}
}// 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));
}00:00 +4: All tests passed!Practice Questions
Beginner
- Write
int square(int n)and test it with4,0and-3. - Name three edge cases worth testing for
int highest(List<int> numbers). bool isAdult(int age) => age >= 18;— which two values test the boundary? Write the tests.
Application
- Write a
Counterwithincrement()and adecrement()that never goes below 0. Test: up twice, down three times, value0. - Write
Student(name, roll)with==andhashCode. Test equality and hash codes. - Zayan deposits 200, withdraws 50, then tries to withdraw 500. Test that the last withdrawal is refused and the balance is 150.
- Explain why the
HardGreetertest in section 8 sometimes passes and sometimes fails.
Challenge
- Write a
Couponwith an expiry date and anisExpiredgetter that uses an injectedClock. Test that it is expired on 1 July 2026 when it ends on 30 June. - Write an
Advisorthat depends onWeatherService { double todayCelsius(); }and returns'Take a jacket'below 15 degrees, else'No jacket'. Test a 10-degree day with a fake. - Write
LowBalanceAlertthat callsNotifier.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
// lib/square.dart
int square(int n) => n * n;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)));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.
test('17 is not an adult', () => expect(isAdult(17), isFalse));
test('18 is an adult', () => expect(isAdult(18), isTrue));00:00 +2: All tests passed!Question 4 — Solution
// lib/counter.dart
class Counter {
int _value = 0;
int get value => _value;
void increment() => _value++;
void decrement() {
if (_value > 0) _value--;
}
}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));
});00:00 +1: All tests passed!Question 5 — Solution
// 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);
}test('equal students', () {
expect(Student('Ali', 7), equals(Student('Ali', 7)));
expect(Student('Ali', 7).hashCode, equals(Student('Ali', 7).hashCode));
});00:00 +1: All tests passed!Question 6 — Solution
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));
});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
// 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);
}// 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);
});00:00 +1: All tests passed!Question 9 — Solution
// 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';
}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'));
});
}00:00 +1: All tests passed!Question 10 — Solution
// 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');
}
}
}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']));
});
}00:00 +1: All tests passed!Quick Revision
- What makes a function pure, and why is it easy to test?
- Name four kinds of edge case.
- Why should a test use
balanceand not_balance? - Why is
DateTime.now()inside a class a problem for tests? - What is a fake?
Key Takeaways
- Code lives in
lib/; tests live intest/and usepackage: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.