本文所有示例均基于 Dart 技术栈,并主要使用 test 和 mockito 这两个核心包。
一、 搭建测试环境与基础断言
万事开头难,但搭建Dart的测试环境却异常简单。首先,在你的 pubspec.yaml 文件中添加依赖。
dev_dependencies:
test: ^1.24.0 # 核心测试框架
# 如果项目是Flutter,则使用 flutter_test,它内置了test包并提供了Widget测试能力
# flutter_test:
# sdk: flutter
接着,在项目根目录创建一个 test 文件夹,你的所有测试文件都将放在这里,通常以 _test.dart 结尾。
现在,让我们从一个最简单的函数开始,学习如何使用断言。
// lib/math_utils.dart - 我们的被测代码
class MathUtils {
/// 计算两个数的和
static int add(int a, int b) => a + b;
/// 判断一个数是否为偶数
static bool isEven(int number) => number % 2 == 0;
/// 获取列表中的最大值,如果列表为空则抛出异常
static int max(List<int> numbers) {
if (numbers.isEmpty) {
throw ArgumentError('列表不能为空');
}
return numbers.reduce((curr, next) => curr > next ? curr : next);
}
}
对应的测试文件如下:
// test/math_utils_test.dart
import 'package:test/test.dart'; // 1. 导入测试包
import '../lib/math_utils.dart'; // 2. 导入被测代码
void main() { // 3. 所有测试的入口点
group('MathUtils 基础功能测试', () { // 4. group用于组织相关测试,让报告更清晰
test('add 函数应能正确计算两数之和', () {
// 断言(Assert):这是我们验证代码行为的工具
// expect(实际值, 匹配器)
expect(MathUtils.add(2, 3), equals(5)); // equals是最常用的匹配器
expect(MathUtils.add(-1, 1), equals(0));
expect(MathUtils.add(0, 0), equals(0));
});
test('isEven 函数应能正确判断奇偶性', () {
expect(MathUtils.isEven(4), isTrue); // isTrue 匹配器
expect(MathUtils.isEven(7), isFalse); // isFalse 匹配器
expect(MathUtils.isEven(0), isTrue); // 0也被认为是偶数
});
test('max 函数应能返回列表最大值,空列表应抛出异常', () {
expect(MathUtils.max([1, 5, 3]), equals(5));
expect(MathUtils.max([-10, -5, -1]), equals(-1));
// 测试异常:使用 throwsA 匹配器配合类型检查
expect(() => MathUtils.max([]), throwsA(isA<ArgumentError>()));
});
});
}
应用场景与优缺点分析:
基础断言是单元测试的“钢筋水泥”。expect 函数配合丰富的匹配器(如 equals, isTrue, throwsA, isNotEmpty 等),几乎能覆盖所有简单逻辑的验证。它的优点在于直观、简单,学习成本低,是测试的起点。缺点是对于依赖外部服务(如数据库、网络API)或复杂对象状态的代码,单纯使用断言会变得笨拙甚至不可行,这就需要我们引入更高级的技术。
二、 异步测试与 Future/Stream 处理
Dart 天生支持异步,我们的测试也必须跟上。处理 Future 和 Stream 是 Dart 单元测试的必备技能。
// lib/data_fetcher.dart
class DataFetcher {
final Duration delay;
DataFetcher({this.delay = const Duration(milliseconds: 100)});
/// 模拟一个异步获取用户数据的Future
Future<String> fetchUserName(int id) async {
await Future.delayed(delay); // 模拟网络延迟
if (id == 1) {
return 'Alice';
} else if (id == 2) {
return 'Bob';
} else {
throw Exception('用户未找到');
}
}
/// 模拟一个产生数字序列的Stream
Stream<int> countNumbers(int upTo) async* {
for (int i = 1; i <= upTo; i++) {
await Future.delayed(Duration(milliseconds: 50));
yield i; // 异步地“产出”每个数字
}
}
}
测试异步代码:
// test/data_fetcher_test.dart
import 'package:test/test.dart';
import '../lib/data_fetcher.dart';
void main() {
group('DataFetcher 异步测试', () {
late DataFetcher fetcher;
setUp(() {
// 每个测试前创建一个新的DataFetcher实例,保证测试隔离
fetcher = DataFetcher(delay: Duration.zero); // 测试时去掉延迟
});
// 测试 Future - 使用 `expect` 配合 `completion` 匹配器,或 `await`
test('fetchUserName 应能成功返回用户名', () async { // 测试函数标记为 async
// 方法一:使用 await,更直观
final name = await fetcher.fetchUserName(1);
expect(name, equals('Alice'));
// 方法二:直接返回Future,expect会等待其完成
expect(fetcher.fetchUserName(2), completion(equals('Bob')));
});
test('fetchUserName 对于非法ID应抛出异常', () {
// 测试异步异常
expect(fetcher.fetchUserName(99), throwsA(isA<Exception>()));
});
// 测试 Stream - 使用 `expectLater` 和 `emitsInOrder` 等匹配器
test('countNumbers 应能按顺序发射数字', () async {
final stream = fetcher.countNumbers(3);
// expectLater 专门用于断言Stream的行为
await expectLater(
stream,
emitsInOrder([
equals(1), // 第一个发射的值是1
equals(2), // 然后是2
equals(3), // 最后是3
emitsDone, // 最后Stream结束
]),
);
});
test('countNumbers 发射数量正确', () async {
final stream = fetcher.countNumbers(5);
// 使用 `emits` 匹配器配合 `predicate` 进行更灵活的断言
await expectLater(
stream,
emits(predicate((int value) {
return value > 0 && value <= 5;
})),
); // 这里只断言了第一个值,实际会检查直到流结束
// 更常见的做法是收集所有值再断言
final collected = await stream.toList();
expect(collected, equals([1, 2, 3, 4, 5]));
});
});
}
注意事项:
异步测试的关键是耐心等待。务必使用 await 或 completion/expectLater 等机制,确保测试框架等待异步操作完成。忘记 await 是初学者最常见的错误,会导致测试在异步操作完成前就结束,从而产生误判。
三、 Mock对象的魔力:隔离与模拟
当你的类依赖于其他服务(如HTTP客户端、数据库仓库、文件系统)时,你不想在单元测试中真正启动这些服务。这时,Mock对象就登场了。它的核心思想是“伪造”一个依赖对象,并精确控制它的行为,让你能专注于测试当前类的逻辑。我们使用 mockito 包。
首先添加依赖:
dev_dependencies:
mockito: ^5.4.0
build_runner: ^2.4.0 # 用于代码生成(如果你使用 `@GenerateMocks`)
让我们看一个典型的场景:一个用户服务依赖于一个用户数据仓库。
// lib/user_repository.dart - 依赖的抽象
abstract class UserRepository {
Future<User> fetchUser(int id);
Future<void> updateUser(User user);
}
class User {
final int id;
final String name;
User({required this.id, required this.name});
}
// lib/user_service.dart - 我们的被测类
class UserService {
final UserRepository repository;
UserService(this.repository); // 依赖注入
Future<String> getUserName(int id) async {
final user = await repository.fetchUser(id);
return '用户: ${user.name}';
}
Future<void> promoteUser(int id) async {
final user = await repository.fetchUser(id);
// ... 一些复杂的业务逻辑 ...
final updatedUser = User(id: user.id, name: '${user.name}(已晋升)');
await repository.updateUser(updatedUser);
}
}
现在,我们为 UserRepository 创建一个 Mock 并测试 UserService。
// test/user_service_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import '../lib/user_service.dart';
import '../lib/user_repository.dart';
// 使用注解生成Mock类。运行 `dart run build_runner build` 生成 mocks 文件。
@GenerateMocks([UserRepository])
import 'user_service_test.mocks.dart'; // 导入生成的mock文件
void main() {
group('UserService 测试(使用Mock)', () {
late UserService userService;
late MockUserRepository mockRepository; // 使用生成的Mock类
setUp(() {
mockRepository = MockUserRepository();
userService = UserService(mockRepository);
});
test('getUserName 应通过仓库获取用户并格式化名字', () async {
// 1. 准备(Arrange):设定Mock对象的行为
const testUser = User(id: 1, name: 'Charlie');
// 当 fetchUser 被以参数 1 调用时,让它返回一个成功的Future
when(mockRepository.fetchUser(1)).thenAnswer((_) async => testUser);
// 2. 执行(Act):调用被测方法
final result = await userService.getUserName(1);
// 3. 断言(Assert):验证结果和交互
expect(result, equals('用户: Charlie'));
// 验证 fetchUser 方法确实被以参数 1 调用了一次
verify(mockRepository.fetchUser(1)).called(1);
// 确保 updateUser 没有被意外调用
verifyNever(mockRepository.updateUser(any));
});
test('promoteUser 应获取用户、处理业务并更新仓库', () async {
const originalUser = User(id: 2, name: 'David');
// 设定 fetchUser 行为
when(mockRepository.fetchUser(2)).thenAnswer((_) async => originalUser);
// 设定 updateUser 行为(它返回 Future<void>)
when(mockRepository.updateUser(any)).thenAnswer((_) async {});
// 执行
await userService.promoteUser(2);
// 验证交互
verify(mockRepository.fetchUser(2)).called(1);
// 捕获传递给 updateUser 的参数,并进行更精细的断言
final capturedUser = verify(mockRepository.updateUser(captureAny)).captured.single as User;
expect(capturedUser.id, equals(2));
expect(capturedUser.name, equals('David(已晋升)'));
});
test('getUserName 在仓库抛出异常时应传播异常', () async {
// 模拟依赖抛出异常
when(mockRepository.fetchUser(any)).thenThrow(Exception('网络错误'));
// 断言我们的服务也抛出了异常
expect(userService.getUserName(999), throwsA(isA<Exception>()));
});
});
}
技术优缺点与注意事项: Mockito 极大地提升了测试的灵活性和隔离性。优点在于:1) 快速:无需真实外部依赖;2) 可靠:测试不因网络、数据库等问题而失败;3) 精准:可以模拟各种边界和异常情况。
但也要注意:1) 过度模拟:Mock了太多细节,导致测试变成了Mock配置的测试,而非业务逻辑测试。2) 维护成本:当被Mock的接口变更时,所有相关的测试Mock配置都需要更新。最佳实践是只Mock那些确实不稳定、速度慢或不受你控制的依赖(如IO、网络、第三方服务),对于简单的值对象或稳定工具类,直接使用真实实例即可。
四、 高级技巧与最佳实践
掌握了基础,我们再来看看一些能让你更上一层楼的技巧。
1. 使用 setUp/tearDown 和 setUpAll/tearDownAll
group('数据库相关测试', () {
late DatabaseConnection connection;
setUpAll(() async {
// 在所有测试开始前执行一次,比如建立数据库连接池
print('初始化测试套件');
});
setUp(() async {
// 在每个测试开始前执行,用于准备测试环境
connection = await DatabaseConnection.open();
await connection.clearTestData(); // 清空旧数据,保证测试独立
});
tearDown(() async {
// 在每个测试结束后执行,用于清理
await connection.close();
});
tearDownAll(() {
// 在所有测试结束后执行一次
print('清理测试套件');
});
});
2. 自定义匹配器
当内置匹配器不够用时,你可以创建自己的匹配器,让断言语句更清晰。
// 自定义一个匹配器,判断字符串是否是有效的邮箱格式
const isEmail = TypeMatcher<String>().having(
(s) => s.contains('@') && s.endsWith('.com'),
'是一个有效邮箱',
true,
);
void main() {
test('验证邮箱格式', () {
expect('test@example.com', isEmail);
expect('invalid-email', isNot(isEmail)); // 使用 isNot 取反
});
}
3. 测试私有成员
通常,我们只测试公共API。但如果确实需要测试私有方法,一个常见模式是通过一个公共的“测试接口”来暴露,或者将测试文件与被测文件放在同一个库中(使用 part/part of,但需谨慎),更推荐的方法是重构代码,将私有逻辑提取到一个新的、可公开测试的类中。
文章总结:
单元测试不是负担,而是你代码的“安全网”和“设计工具”。从基础断言开始,你验证了每一块“砖头”是否牢固。通过异步测试,你确保了在“单线程异步”这个世界里,代码依然行为正确。而引入Mock对象,则让你拥有了“上帝视角”,可以任意操控依赖,在绝对隔离的环境中聚焦核心逻辑。Dart的 test 和 mockito 包提供了强大而优雅的工具链。记住,好的测试应该是 F.I.R.S.T 的:快速(Fast)、独立(Independent)、可重复(Repeatable)、自我验证(Self-Validating)及时(Timely)。从现在开始,为你写的每一段重要业务逻辑配上测试,你会发现代码质量、重构信心和个人开发体验都会获得巨大的提升。测试驱动开发(TDD)或许是一个更遥远的彼岸,但拥有扎实的单元测试技能,无疑是驶向那个彼岸最坚固的船只。
评论