简介
单元测试是测试代码最小单元的方法,能够帮助我们及早发现问题。本文介绍如何在 Flutter 中编写单元测试。
基本配置
添加依赖
dev_dependencies:
test: ^1.24.1
mockito: ^5.4.2
build_runner: ^2.4.6
创建测试文件
// test/calculator_test.dart
import 'package:test/test.dart';
import 'package:my_app/calculator.dart';
void main() {
test('Calculator should add two numbers', () {
final calculator = Calculator();
expect(calculator.add(2, 3), equals(5));
});
}
测试组织
测试组
group('Calculator', () {
late Calculator calculator;
setUp(() {
calculator = Calculator();
});
test('should add two numbers', () {
expect(calculator.add(2, 3), equals(5));
});
test('should subtract two numbers', () {
expect(calculator.subtract(5, 3), equals(2));
});
test('should multiply two numbers', () {
expect(calculator.multiply(2, 3), equals(6));
});
test('should divide two numbers', () {
expect(calculator.divide(6, 2), equals(3));
});
});
生命周期钩子
group('UserRepository', () {
late UserRepository repository;
late MockDatabase database;
setUpAll(() {
// 在所有测试开始前执行一次
});
setUp(() {
// 每个测试前执行
database = MockDatabase();
repository = UserRepository(database);
});
tearDown(() {
// 每个测试后执行
});
tearDownAll(() {
// 在所有测试结束后执行一次
});
test('should save user', () async {
final user = User('John');
await repository.saveUser(user);
verify(database.insert(user)).called(1);
});
});
Mock 对象
创建 Mock
// 生成 Mock 类
@GenerateMocks([Database])
void main() {
// 使用生成的 Mock 类
final database = MockDatabase();
when(database.query('users'))
.thenAnswer((_) => Future.value([
{'id': 1, 'name': 'John'},
{'id': 2, 'name': 'Jane'},
]));
// 验证调用
verify(database.query('users')).called(1);
// 验证未调用
verifyNever(database.delete('users'));
}
异步测试
test('should fetch users', () async {
final repository = UserRepository(database);
when(database.query('users'))
.thenAnswer((_) => Future.value([
{'id': 1, 'name': 'John'},
]));
final users = await repository.getUsers();
expect(users, hasLength(1));
expect(users.first.name, equals('John'));
});
完整示例
// 被测试的类
class UserService {
final Database database;
final ApiClient apiClient;
UserService(this.database, this.apiClient);
Future<User?> getUserById(int id) async {
try {
final data = await database.query(
'users',
where: 'id = ?',
whereArgs: [id],
);
if (data.isEmpty) {
// 从 API 获取
final user = await apiClient.getUser(id);
if (user != null) {
await database.insert('users', user.toMap());
}
return user;
}
return User.fromMap(data.first);
} catch (e) {
return null;
}
}
Future<List<User>> searchUsers(String query) async {
if (query.isEmpty) return [];
final data = await database.query(
'users',
where: 'name LIKE ?',
whereArgs: ['%$query%'],
);
return data.map((e) => User.fromMap(e)).toList();
}
Future<bool> updateUser(User user) async {
try {
// 先更新 API
final success = await apiClient.updateUser(user);
if (!success) return false;
// 再更新数据库
await database.update(
'users',
user.toMap(),
where: 'id = ?',
whereArgs: [user.id],
);
return true;
} catch (e) {
return false;
}
}
}
// 测试文件
@GenerateMocks([Database, ApiClient])
void main() {
group('UserService', () {
late UserService service;
late MockDatabase database;
late MockApiClient apiClient;
setUp(() {
database = MockDatabase();
apiClient = MockApiClient();
service = UserService(database, apiClient);
});
group('getUserById', () {
test('should return user from database when exists', () async {
// Arrange
when(database.query(
'users',
where: 'id = ?',
whereArgs: [1],
)).thenAnswer((_) => Future.value([
{'id': 1, 'name': 'John'},
]));
// Act
final user = await service.getUserById(1);
// Assert
expect(user, isNotNull);
expect(user!.id, equals(1));
expect(user.name, equals('John'));
verify(database.query(
'users',
where: 'id = ?',
whereArgs: [1],
)).called(1);
verifyNever(apiClient.getUser(any));
});
test('should fetch from API when not in database', () async {
// Arrange
when(database.query(
'users',
where: 'id = ?',
whereArgs: [1],
)).thenAnswer((_) => Future.value([]));
when(apiClient.getUser(1))
.thenAnswer((_) => Future.value(User(1, 'John')));
// Act
final user = await service.getUserById(1);
// Assert
expect(user, isNotNull);
expect(user!.id, equals(1));
expect(user.name, equals('John'));
verify(apiClient.getUser(1)).called(1);
verify(database.insert('users', any)).called(1);
});
});
});
}
最佳实践
- 纯函数优先:正文
UserService那种带 database+apiClient 的类要写一堆when/verify,但JsonConverter.convertJsonToDart这种纯函数(实战案例)一行expect就能测。重构前先把纯逻辑抽出来,测试成本直接砍半。 - Mock 只 mock 边界:database、apiClient 这类 IO 边界值得 mock;mock 了 5 层还断言 mock 本身(比如被测代码只是转发,却
verify转发被调用了)等于在测 mockito 框架。经验值:一个用例里when超过 3 个,说明被测类职责太重,该拆了。 - 边界条件比 happy path 更值钱:
divide(6, 0)抛 ArgumentError、searchUsers('')直接返回[]、getUserById 数据库命中时不走 API——正文示例的用例里真正有价值的就是这几个分支。每个 if/else 至少一个用例。 - 异步测试必须 await 到底:漏写
await的 Future 测试会”假通过”——断言在 Future 完成前就跑完了。看到test('...', () async就检查最后一行有没有 await 被测调用。 - 用 group+命名说清场景:
group('getUserById')下面是'should return user from database when exists',失败时一眼知道是”数据库命中”分支挂了,而不是对着test 3 failed猜。
注意事项
- 别测试实现细节:断言”调用了
database.query('users', where: 'id = ?')”是实现细节——明天改成缓存优先,测试全红但功能没坏。断言返回值和可观察行为;verify调用次数只用在”必须发生一次”的副作用上(比如 API 回填时的 insert)。 - Mock 的
thenAnswer别写死大数据:thenAnswer((_) => Future.value([...100条...]))让测试又慢又难读。只给刚好触发分支的最小数据——getUserById 的用例 1 条记录就够了。 setUp里重建依赖:database/apiClient 每个用例新建(正文就是这么写的)。Mock 是有状态的——上一个用例的when会污染下一个,出现”本地全过、CI 随机挂”。- 控制粒度:一个 test 只断言一个行为。正文
'should fetch from API when not in database'同时 verify 了apiClient.getUser和database.insert——这是可接受的上限,再往下加断言就该拆成两个用例。 - 别为私有方法写测试:
_parseUser测不透,就说明它该被抽成公开的纯函数(或独立类)。通过公开 API 覆盖私有逻辑——私有方法改名不该导致测试重写。
实战案例
单元测试我写得不多,但有个绝佳的候选:ygking_json_to_dart_model 的 JsonConverter。它是纯 Dart 类(143 行,零 Flutter 依赖),convertJsonToDart 是纯函数——输入 JSON 字符串,输出 Dart 代码字符串。这种函数是单元测试的完美靶子:expect(converter.convertJsonToDart('{"name":"a"}', 'User'), contains('class User')),再补几个边界(空数组、嵌套对象、非法 JSON)。我没写(工具类项目,写完就扔),但如果重来,我会先写测试——因为”字符串生成”类逻辑最容易被改坏,加个功能崩三个 case。纯函数优先写单测,有 UI 依赖的往后排,这是投入产出比最高的测试策略。
总结
单元测试是保证代码质量的重要手段。通过编写全面的单元测试,可以提高代码的可靠性和可维护性。
