Dart 单元测试
单元测试是保证代码质量的基础手段。
它验证每个最小功能单元(通常是函数或方法)的行为是否符合预期。
本章介绍 Dart 的 test 包使用、test() 测试用例编写、expect 断言以及 group() 分组测试。
test 包的安装与配置
Dart 官方提供了 test 包来编写和运行测试。
首先在 pubspec.yaml 中添加依赖:
# 文件路径:pubspec.yaml dev_dependencies: test: ^1.24.0
然后运行以下命令安装:
$ dart pub get
测试文件通常放在项目的 test/ 目录下,文件名以 _test.dart 结尾。
一个典型的项目结构:
my_project/ ├── lib/ │ └── calculator.dart # 被测试的代码 ├── test/ │ └── calculator_test.dart # 测试文件 └── pubspec.yaml
编写 test() 测试用例
test() 函数是编写测试用例的基本单元。
它接收两个参数:测试描述(字符串)和测试函数体。
实例
被测试的代码(lib/calculator.dart):
class Calculator {
int add(int a, int b) => a + b;
int subtract(int a, int b) => a - b;
int multiply(int a, int b) => a * b;
double divide(int a, int b) {
if (b == 0) {
throw ArgumentError('除数不能为 0');
}
return a / b;
}
bool isEven(int n) => n % 2 == 0;
List<int> filterPositive(List<int> numbers) {
return numbers.where((n) => n > 0).toList();
}
}
实例
测试文件(test/calculator_test.dart):
import 'package:test/test.dart';
import 'package:my_project/calculator.dart';
void main() {
// 创建测试用的 Calculator 实例
var calculator = Calculator();
// test() 函数:第一个参数是测试描述,第二个是测试函数
test('add() 两个正数相加', () {
var result = calculator.add(3, 5);
// expect() 断言:验证实际结果是否等于期望值
expect(result, equals(8));
});
test('add() 包含负数相加', () {
expect(calculator.add(-3, 5), equals(2));
expect(calculator.add(-3, -5), equals(-8));
});
test('subtract() 减法运算', () {
expect(calculator.subtract(10, 3), equals(7));
});
test('multiply() 乘法运算', () {
expect(calculator.multiply(4, 5), equals(20));
// 乘以零
expect(calculator.multiply(100, 0), equals(0));
});
test('divide() 正常除法', () {
expect(calculator.divide(10, 2), equals(5.0));
expect(calculator.divide(7, 2), equals(3.5));
});
test('isEven() 偶数判断', () {
expect(calculator.isEven(4), isTrue);
expect(calculator.isEven(5), isFalse);
});
}
运行测试:
$ dart test
00:00 +6: All tests passed!
expect 断言
expect() 是测试中最核心的函数,它验证实际值是否满足某个条件。
基本语法:expect(actual, matcher)。
常用 Matcher
| Matcher | 用途 | 示例 |
|---|---|---|
| equals(expected) | 验证值相等 | expect(result, equals(42)) |
| isTrue / isFalse | 验证布尔值 | expect(flag, isTrue) |
| isNull / isNotNull | 验证 null | expect(value, isNull) |
| contains(value) | 包含某个元素(列表)或子串(字符串) | expect(list, contains('a')) |
| isA | 验证类型 | expect(obj, isA |
| throws | 验证抛出异常 | expect(() => f(), throwsException) |
| isNotEmpty | 非空 | expect(list, isNotEmpty) |
| hasLength(n) | 验证长度 | expect(list, hasLength(3)) |
| greaterThan(n) | 大于 | expect(score, greaterThan(60)) |
| closeTo(value, delta) | 浮点数近似相等 | expect(3.14, closeTo(3.1, 0.1)) |
实例
各种 Matcher 的实际应用:
void main() {
test('RUNOOB 各种断言示例', () {
// 基本相等
expect(2 + 2, equals(4));
// 布尔值
expect('hello'.contains('h'), isTrue);
expect(''.isEmpty, isTrue);
// null 检查
String? name;
expect(name, isNull);
name = 'RUNOOB';
expect(name, isNotNull);
// 类型检查
expect('RUNOOB', isA<String>());
expect(42, isA<int>());
// 列表/字符串包含
expect([1, 2, 3], contains(2));
expect('Hello, RUNOOB!', contains('RUNOOB'));
// 长度
expect([1, 2, 3], hasLength(3));
expect('Dart', hasLength(4));
// 数值比较
expect(100, greaterThan(50));
expect(30, lessThan(60));
expect(75, greaterThanOrEqualTo(60));
// 浮点数比较(避免精度问题)
expect(0.1 + 0.2, closeTo(0.3, 0.001));
// 集合非空
expect([1, 2], isNotEmpty);
// 列表相等
expect([1, 2, 3], equals([1, 2, 3]));
// Map 包含某个键
var user = {'name': 'runoob', 'age': 10};
expect(user, containsPair('name', 'runoob'));
expect(user.keys, contains('age'));
});
}
00:00 +1: All tests passed!
测试异常
实例
int divide(int a, int b) {
if (b == 0) throw ArgumentError('除数不能为 0');
return a ~/ b;
}
void main() {
test('divide() 除零应该抛出异常', () {
// 验证抛出任意异常
expect(() => divide(10, 0), throwsException);
// 验证抛出特定类型的异常
expect(() => divide(10, 0), throwsArgumentError);
// 验证异常消息
expect(
() => divide(10, 0),
throwsA(predicate((e) =>
e is ArgumentError &&
e.message.contains('除数'))),
);
});
test('divide() 正常情况不抛异常', () {
expect(() => divide(10, 2), returnsNormally);
expect(divide(10, 2), equals(5));
});
}
00:00 +2: All tests passed!
测试异常时,需要将可能抛异常的代码包装在匿名函数中(() => code),而不是直接调用。如果直接写 expect(divide(10, 0), throwsException),divide 会立即抛出异常,expect 根本没机会执行。
分组测试 group()
group() 用于将相关的测试用例组织在一起,让测试结构更清晰。
实例
// 被测试的函数
class StringUtils {
static String capitalize(String s) {
if (s.isEmpty) return s;
return s[0].toUpperCase() + s.substring(1);
}
static String reverse(String s) {
return s.split('').reversed.join('');
}
static bool isPalindrome(String s) {
var clean = s.toLowerCase().replaceAll(' ', '');
return clean == reverse(clean);
}
static int countWords(String s) {
if (s.trim().isEmpty) return 0;
return s.trim().split(RegExp(r'\s+')).length;
}
}
void main() {
// group() 嵌套组织测试
group('StringUtils', () {
// 子分组
group('capitalize()', () {
test('正常单词首字母大写', () {
expect(StringUtils.capitalize('hello'), equals('Hello'));
});
test('已大写的单词不变', () {
expect(StringUtils.capitalize('Hello'), equals('Hello'));
});
test('空字符串返回空字符串', () {
expect(StringUtils.capitalize(''), equals(''));
});
test('单个字符', () {
expect(StringUtils.capitalize('a'), equals('A'));
});
});
group('reverse()', () {
test('反转正常字符串', () {
expect(StringUtils.reverse('RUNOOB'), equals('BOONUR'));
});
test('反转回文字符串不变', () {
expect(StringUtils.reverse('aba'), equals('aba'));
});
test('空字符串', () {
expect(StringUtils.reverse(''), equals(''));
});
});
group('isPalindrome()', () {
test('回文字符串返回 true', () {
expect(StringUtils.isPalindrome('racecar'), isTrue);
expect(StringUtils.isPalindrome('A man a plan a canal Panama'), isTrue);
});
test('非回文字符串返回 false', () {
expect(StringUtils.isPalindrome('hello'), isFalse);
});
test('空字符串视为回文', () {
expect(StringUtils.isPalindrome(''), isTrue);
});
});
group('countWords()', () {
test('正常句子', () {
expect(StringUtils.countWords('Hello World Dart'), equals(3));
});
test('多余空格', () {
expect(StringUtils.countWords(' Hello World '), equals(2));
});
test('空字符串', () {
expect(StringUtils.countWords(''), equals(0));
expect(StringUtils.countWords(' '), equals(0));
});
});
});
}
00:00 +12: All tests passed!
group() 可以嵌套,创建层次化的测试结构。
这让测试报告更易读,也方便快速定位失败的测试。
一个好的分组策略是按"被测试的模块/类/方法"来组织。这样当某个测试失败时,你能立即知道是哪个功能出了问题。
setUp 和 tearDown
setUp 在每个 test 之前运行,tearDown 在每个 test 之后运行。
它们用于准备测试环境和清理资源。
实例
// 模拟一个需要初始化和清理的类
class Database {
bool isConnected = false;
void connect() {
isConnected = true;
print(' 数据库已连接');
}
void disconnect() {
isConnected = false;
print(' 数据库已断开');
}
String query(String sql) {
if (!isConnected) throw Exception('未连接数据库');
return '查询结果: $sql';
}
}
void main() {
group('Database 测试', () {
late Database db; // late 延迟初始化
// 每个 test 之前执行
setUp(() {
db = Database();
db.connect();
print(' [setUp] 准备测试环境');
});
// 每个 test 之后执行
tearDown(() {
db.disconnect();
print(' [tearDown] 清理测试环境');
});
test('query() 正常查询', () {
var result = db.query('SELECT * FROM users');
expect(result, contains('RUNOOB') ? result.contains('users') : result.contains('users'));
// 简化断言
expect(result, isNotEmpty);
});
test('query() 连接后可以执行多次查询', () {
var r1 = db.query('SELECT 1');
var r2 = db.query('SELECT 2');
expect(r1, isNotEmpty);
expect(r2, isNotEmpty);
});
test('未连接时查询会抛异常', () {
db.disconnect(); // 手动断开
expect(() => db.query('SELECT 1'), throwsException);
});
});
}
数据库已连接 [setUp] 准备测试环境 数据库已断开 [tearDown] 清理测试环境 数据库已连接 [setUp] 准备测试环境 数据库已断开 [tearDown] 清理测试环境 数据库已连接 [setUp] 准备测试环境 数据库已断开 [tearDown] 清理测试环境 00:00 +3: All tests passed!
注意输出中 setUp 和 tearDown 的执行顺序——每个 test 前后都会执行一次。
setUp 和 tearDown 确保每个测试从一个干净的状态开始,不受其他测试的影响。这是测试独立性的关键保证。
异步测试
Dart test 包原生支持异步测试。
测试函数可以返回 Future,框架会自动等待 Future 完成。
实例
// 异步函数:模拟网络请求
Future<String> fetchUserData(int userId) async {
await Future.delayed(Duration(milliseconds: 100));
if (userId <= 0) {
throw Exception('无效的用户 ID');
}
return '用户 $userId 的数据';
}
Future<List<int>> fetchNumbers() async {
await Future.delayed(Duration(milliseconds: 50));
return [1, 2, 3, 4, 5];
}
void main() {
group('异步测试', () {
test('fetchUserData() 正常获取数据', () async {
var data = await fetchUserData(1);
expect(data, equals('用户 1 的数据'));
});
test('fetchUserData() 无效 ID 抛异常', () async {
expect(
() => fetchUserData(-1),
throwsA(isA<Exception>()),
);
});
test('fetchNumbers() 返回正确的列表', () async {
var numbers = await fetchNumbers();
expect(numbers, hasLength(5));
expect(numbers, contains(3));
expect(numbers.first, equals(1));
});
// 多个异步操作的测试
test('连续异步操作', () async {
var data1 = await fetchUserData(1);
var data2 = await fetchUserData(2);
expect(data1, isNot(equals(data2)));
expect(data1, contains('1'));
expect(data2, contains('2'));
});
});
}
00:00 +4: All tests passed!
运行测试的常用命令
| 命令 | 功能 |
|---|---|
| dart test | 运行所有测试 |
| dart test test/calculator_test.dart | 运行指定测试文件 |
| dart test --name="add" | 只运行名称包含 "add" 的测试 |
| dart test --concurrency=4 | 并发运行测试(加速) |
| dart test --reporter=expanded | 详细输出模式 |
| dart test --coverage=coverage | 生成测试覆盖率数据 |
测试最佳实践
| 实践 | 说明 |
|---|---|
| 每个测试只验证一件事 | 一个 test() 对应一个行为,失败时定位更快 |
| 测试名称描述行为而非实现 | "add() 两个正数相加" 比 "add() 测试" 更好 |
| AAA 模式 | Arrange(准备)→ Act(执行)→ Assert(断言) |
| 先写失败的测试 | 确认测试能捕获错误,再写代码让它通过 |
| 边界条件测试 | 空值、零、负数、极大值、极小值 |
| 测试之间保持独立 | 一个测试的结果不应影响另一个测试 |
测试不是负担,而是保险。你写的测试越多,重构时就越有信心。当一个测试失败时,你不需要猜是哪里出了问题——测试会精确地告诉你。
本章小结
本章介绍了 Dart 单元测试的完整流程:test 包的安装、test() 测试用例的编写、expect 断言的使用、group() 分组组织、setUp/tearDown 环境管理以及异步测试。
单元测试是专业开发者的必修课,养成写测试的习惯会让你的代码质量持续提升。
全教程总结
恭喜你完成了 Dart 编程语言的入门学习!
让我们回顾一下这 21 章的学习路径:
| 阶段 | 章节 | 核心内容 |
|---|---|---|
| 一、入门基础 | 1-4 章 | Dart 概览、环境搭建、第一个程序、基础语法 |
| 二、核心类型与控制流 | 5-8 章 | 变量与数据类型、运算符、控制流、集合 |
| 三、函数与面向对象 | 9-13 章 | 函数、类与对象、继承与多态、接口与 Mixin、泛型 |
| 四、进阶特性 | 14-17 章 | 枚举与符号、异常处理、包与库管理、typedef |
| 五、异步编程与测试 | 18-21 章 | 异步编程、Stream 流、并发与 Isolate、单元测试 |
掌握了这些内容,你已经具备了使用 Dart 进行日常开发的能力。
下一步可以深入学习 Flutter 框架,将 Dart 知识应用到移动端和 Web 端的 UI 开发中。
