Unit Testing Fundamentals
Unit Testing Fundamentals
Unit testing is the practice of verifying that individual functions and classes behave exactly as expected in isolation. In Dart and Flutter, unit tests run on the Dart VM without a device or simulator, making them the fastest category of tests in the testing pyramid. A well-written suite of unit tests gives you confidence to refactor code and add features without introducing regressions.
The test package is the foundation for all testing in the Dart ecosystem. It is a direct dependency of flutter_test, which means it is already available in every Flutter project. Add it explicitly under dev_dependencies if you are writing pure Dart packages:
pubspec.yaml — adding the test package
dev_dependencies:
flutter_test:
sdk: flutter
test: ^1.24.0 # For pure-Dart packages outside Flutter
The test() and expect() Functions
Every unit test is defined with the global test() function. It accepts a description string and a callback containing your assertions. Inside the callback you use expect() to compare the actual value produced by your code against a matcher that describes the expected result.
Basic test() and expect() usage
import 'package:test/test.dart';
// Pure Dart function under test
int add(int a, int b) => a + b;
String greet(String name) => 'Hello, $name!';
void main() {
test('add returns the sum of two integers', () {
expect(add(2, 3), equals(5));
expect(add(-1, 1), equals(0));
expect(add(0, 0), equals(0));
});
test('greet returns a personalised greeting', () {
expect(greet('Edrees'), equals('Hello, Edrees!'));
expect(greet(''), equals('Hello, !'));
});
}
test/ directory and their filenames must end in _test.dart. Run them with flutter test (Flutter projects) or dart test (pure Dart packages).Organising Tests with group()
When you have many tests for the same class or module, wrapping them in a group() block improves readability and keeps failure messages contextual. Groups can also be nested.
Grouping related tests
import 'package:test/test.dart';
class Calculator {
int add(int a, int b) => a + b;
int subtract(int a, int b) => a - b;
double divide(int a, int b) {
if (b == 0) throw ArgumentError('Cannot divide by zero');
return a / b;
}
}
void main() {
group('Calculator', () {
late Calculator calc;
setUp(() {
calc = Calculator(); // fresh instance before each test
});
group('add', () {
test('returns correct sum for positive numbers', () {
expect(calc.add(3, 4), equals(7));
});
test('handles negative operands', () {
expect(calc.add(-5, 3), equals(-2));
});
});
group('divide', () {
test('returns a double quotient', () {
expect(calc.divide(10, 4), equals(2.5));
});
test('throws ArgumentError when dividing by zero', () {
expect(() => calc.divide(5, 0), throwsA(isA<ArgumentError>()));
});
});
});
}
setUp() and tearDown()
setUp() runs its callback before every test in the current scope. tearDown() runs its callback after every test, even if the test threw. Use them to create fresh fixtures and release resources (database connections, file handles, mock objects) so tests remain independent and do not bleed state into each other.
setUpAll()— runs once before the entire group (expensive setup like spinning up a server)tearDownAll()— runs once after the entire group (tear down that server)setUp()— runs before each test (create a clean object instance)tearDown()— runs after each test (close streams, reset singletons)
late at the group scope and initialise them inside setUp(). This guarantees every test starts with a pristine instance and prevents subtle ordering bugs.Common Matchers
The test package ships with a rich set of built-in matchers that make assertion failures self-documenting:
equals(value)— deep equality (use for primitives, lists, maps)isTrue/isFalse— boolean checksisNull/isNotNullisA<Type>()— type check (isA<String>())throwsA(matcher)— verifies a function throws a matching exceptionthrowsArgumentError,throwsStateError— convenience shortcutscontains(element)— checks a list, string, or map contains a valuehasLength(n)— checks length of a collection or string
Matchers in practice
void main() {
test('matcher examples', () {
final items = <String>['apple', 'banana', 'cherry'];
expect(items, hasLength(3));
expect(items, contains('banana'));
expect(items.first, isA<String>());
expect(items.isEmpty, isFalse);
String? maybeNull;
expect(maybeNull, isNull);
expect(
() => int.parse('not a number'),
throwsA(isA<FormatException>()),
);
});
}
Testing Asynchronous Code
Dart is asynchronous at its core. Mark your test callback as async and use await exactly as you would in production code. The test runner handles the Future automatically.
Testing a Future-returning function
import 'package:test/test.dart';
Future<String> fetchUsername(int id) async {
// Simulate async data fetch
await Future.delayed(const Duration(milliseconds: 10));
if (id <= 0) throw ArgumentError('id must be positive');
return 'user_$id';
}
void main() {
test('fetchUsername returns formatted username', () async {
final result = await fetchUsername(42);
expect(result, equals('user_42'));
});
test('fetchUsername throws for non-positive id', () async {
expect(
() async => fetchUsername(-1),
throwsA(isA<ArgumentError>()),
);
});
}
async/await in asynchronous tests. A test callback that returns a Future without being marked async may appear to pass while the assertion never actually ran.Summary
Unit tests are the backbone of a reliable Dart and Flutter codebase. Remember these key points:
- Use
test()to define a test andexpect()to assert outcomes with descriptive matchers. - Use
group()to organise tests logically and keep failure output contextual. - Use
setUp()andtearDown()to prepare and clean up state around each test, ensuring full isolation. - Mark test callbacks
asyncwhen testingFuture-returning code. - Run all tests with
flutter testand aim for fast, deterministic, and side-effect-free tests.