Testing Classes, State, and Edge Cases
Testing Classes, State, and Edge Cases
Writing tests for individual functions is a good start, but real applications are built from classes that hold state and expose methods that interact with each other. In this lesson you will learn how to use group() to organise related tests, how to test stateful Dart classes thoroughly, and how to systematically cover the edge cases — null inputs, boundary values, and error paths — that are most likely to contain bugs.
Organising Tests with group()
The group() function from the flutter_test / test package lets you nest related tests under a shared description. Groups can be nested inside other groups, and each group can have its own setUp() and tearDown() callbacks that run before and after every test within that group.
Using group() to Structure a Test File
import 'package:test/test.dart';
import 'package:my_app/models/cart.dart';
void main() {
group('Cart', () {
late Cart cart;
setUp(() {
// Runs before EVERY test in this group
cart = Cart();
});
group('addItem', () {
test('increases item count by 1', () {
cart.addItem(Item(id: '1', price: 9.99));
expect(cart.itemCount, equals(1));
});
test('accumulates total price correctly', () {
cart.addItem(Item(id: '1', price: 9.99));
cart.addItem(Item(id: '2', price: 4.50));
expect(cart.total, closeTo(14.49, 0.001));
});
});
group('removeItem', () {
test('decreases item count by 1', () {
cart.addItem(Item(id: '1', price: 9.99));
cart.removeItem('1');
expect(cart.itemCount, equals(0));
});
test('throws StateError when item is not in cart', () {
expect(
() => cart.removeItem('nonexistent'),
throwsA(isA<StateError>()),
);
});
});
});
}
setUp() creates a fresh Cart instance, so no test can accidentally pollute the state seen by another test. Always reset mutable objects in setUp() rather than at the top of the file.Testing Stateful Classes
A stateful class maintains internal data that changes as its methods are called. To test it reliably you need to:
- Verify the initial state immediately after construction.
- Test each transition: call a method and assert the resulting state.
- Test sequences of calls to catch interactions between methods.
- Assert that side effects (events emitted, listeners called, streams updated) occur.
Testing a Stateful Counter Class
// The class under test
class Counter {
int _value;
final int min;
final int max;
Counter({this.min = 0, this.max = 10}) : _value = min;
int get value => _value;
void increment() {
if (_value < max) _value++;
}
void decrement() {
if (_value > min) _value--;
}
void reset() => _value = min;
}
// Test file
import 'package:test/test.dart';
void main() {
group('Counter', () {
group('initial state', () {
test('starts at min value', () {
final c = Counter(min: 3, max: 10);
expect(c.value, equals(3));
});
test('defaults min=0, max=10', () {
final c = Counter();
expect(c.value, equals(0));
});
});
group('increment', () {
test('increases value by 1', () {
final c = Counter();
c.increment();
expect(c.value, equals(1));
});
test('does not exceed max', () {
final c = Counter(max: 2);
c.increment();
c.increment();
c.increment(); // should be clamped
expect(c.value, equals(2));
});
});
group('reset', () {
test('restores value to min', () {
final c = Counter(min: 5, max: 20);
c.increment();
c.increment();
c.reset();
expect(c.value, equals(5));
});
});
});
}
Covering Edge Cases Systematically
Edge cases are the inputs or conditions at the boundary of valid and invalid behaviour. They are responsible for a disproportionate share of production bugs. A practical checklist for every method:
- Null / absent input — what happens when an optional argument is omitted or an object reference is null?
- Boundary values — test exactly at the limit (e.g.,
max), one below (max - 1), and one above (max + 1). - Empty collections — empty list, empty string, zero-length map.
- Negative numbers — when the domain is supposed to be positive-only.
- Error paths — confirm that exceptions or
Errorobjects are thrown with meaningful messages when preconditions are violated.
Edge Case Tests for a Password Validator
// Validator under test
class PasswordValidator {
static const int minLength = 8;
String? validate(String? password) {
if (password == null || password.isEmpty) {
return 'Password must not be empty';
}
if (password.length < minLength) {
return 'Password must be at least $minLength characters';
}
if (!password.contains(RegExp(r'[A-Z]'))) {
return 'Password must contain an uppercase letter';
}
return null; // valid
}
}
// Tests
void main() {
final validator = PasswordValidator();
group('PasswordValidator', () {
// Null / empty edge cases
test('returns error for null password', () {
expect(validator.validate(null), isNotNull);
});
test('returns error for empty string', () {
expect(validator.validate(''), isNotNull);
});
// Boundary values
test('rejects password of exactly minLength - 1 chars', () {
final short = 'Abcdef1'; // 7 chars
expect(validator.validate(short), isNotNull);
});
test('accepts password of exactly minLength chars', () {
final exact = 'Abcdef12'; // 8 chars
expect(validator.validate(exact), isNull);
});
// Error path
test('returns error when no uppercase letter is present', () {
expect(validator.validate('abcdefgh'), isNotNull);
});
// Happy path
test('returns null for a fully valid password', () {
expect(validator.validate('Secure#99'), isNull);
});
});
}
Testing Async State Changes
Many real classes load data asynchronously. Wrap such tests in async / await and use expectLater with stream matchers when the class exposes a Stream or ValueNotifier.
Summary
Well-structured tests use group() to mirror the shape of the code being tested. For every class, verify the initial state, each method transition, meaningful sequences, and all edge cases: null inputs, boundary values, empty collections, and error paths. Consistent use of setUp() keeps tests independent and reliable. This discipline transforms testing from an afterthought into a design tool that improves your code before it ships.