在前几节课中,我们学习了性能优化的基础知识,掌握了从代码层面提升程序效率的技巧。今天我们将聚焦于单元测试------ 这是保证代码质量的关键手段,能够帮助我们在开发早期发现问题,减少线上故障,同时让代码更易于维护和重构。
一、单元测试基础:为什么需要单元测试?
单元测试(Unit Testing)是对软件中最小可测试单元(通常是函数、方法或类)进行的测试。它的核心价值在于:
- 提前发现问题:在开发阶段就能发现代码中的逻辑错误,避免问题被带到生产环境。
- 保障重构安全:当修改或重构代码时,单元测试可以快速验证功能是否依然正常。
- 文档作用:测试用例本身就是一种代码文档,清晰展示了函数的预期行为。
- 促进模块化设计:编写可测试的代码会促使我们写出更松散耦合、职责单一的模块。
在 Dart 生态中,官方推荐的单元测试框架是 test
包,它提供了简洁的 API 和丰富的断言方法,让测试编写变得简单高效。
二、测试框架(test 包)的安装与配置
1. 新建项目(如果没有现有项目)
首先,我们需要一个 Dart 项目(Flutter 项目也适用,配置方式相同):
bash
# 创建普通 Dart 项目
dart create -t console-simple my_test_project
cd my_test_project
2. 添加 test 依赖
在 pubspec.yaml
文件中添加 test
作为开发依赖(dev_dependencies
):
yaml
name: my_test_project
description: A simple console application.
environment:
sdk: '>=3.0.0 <4.0.0'
# 生产依赖(实际运行时需要)
dependencies:
# 开发依赖(仅开发和测试时需要)
dev_dependencies:
test: ^1.25.15 # 添加 test 包
保存后执行 dart pub get
安装依赖:
bash
dart pub get
3. 项目结构准备
按照 Dart 社区的惯例,我们通常将测试代码放在项目根目录的 test
文件夹中,与 lib
文件夹对应:
plaintext
my_test_project/
├── lib/
│ └── math_utils.dart # 待测试的代码
├── test/
│ └── math_utils_test.dart # 测试代码
└── pubspec.yaml
我们先在 lib/math_utils.dart
中编写一些待测试的工具函数:
dart
// lib/math_utils.dart
/// 加法函数
int add(int a, int b) {
return a + b;
}
/// 减法函数
int subtract(int a, int b) {
return a - b;
}
/// 乘法函数
int multiply(int a, int b) {
return a * b;
}
/// 除法函数(仅支持整数除法,除数为0时返回null)
int? divide(int a, int b) {
if (b == 0) return null;
return a ~/ b;
}
三、编写第一个测试用例:test () 与 expect ()
1. 测试文件的基本结构
在 test/math_utils_test.dart
中编写测试代码,基本结构如下:
dart
// test/math_utils_test.dart
// 导入测试框架
import 'package:test/test.dart';
// 导入待测试的代码
import 'package:my_test_project/math_utils.dart';
void main() {
// 测试组:通常以被测试的模块命名
group('MathUtils', () {
// 测试用例:测试 add 函数
test('add should return sum of two numbers', () {
// 测试逻辑
});
});
}
group()
:用于将相关的测试用例分组,第一个参数是组名称,第二个参数是包含测试用例的函数。test()
:定义单个测试用例,第一个参数是测试用例描述(应清晰说明测试内容),第二个参数是测试逻辑函数。
2. 使用 expect () 进行断言
expect(actual, matcher)
是测试中最核心的函数,用于验证实际结果是否符合预期:
actual
:实际执行的结果(如函数返回值)。matcher
:预期的匹配器(可以是具体值、类型、或特殊匹配规则)。
我们为 math_utils.dart
中的每个函数编写测试用例:
dart
// test/math_utils_test.dart
import 'package:test/test.dart';
import 'package:my_test_project/math_utils.dart';
void main() {
group('MathUtils', () {
// 测试 add 函数
test('add(2, 3) should return 5', () {
final result = add(2, 3);
expect(result, 5); // 验证结果是否为5
});
test('add(-1, 1) should return 0', () {
expect(add(-1, 1), 0); // 简洁写法
});
// 测试 subtract 函数
test('subtract(5, 3) should return 2', () {
expect(subtract(5, 3), 2);
});
test('subtract(3, 5) should return -2', () {
expect(subtract(3, 5), -2);
});
// 测试 multiply 函数
test('multiply(4, 5) should return 20', () {
expect(multiply(4, 5), 20);
});
test('multiply(0, 10) should return 0', () {
expect(multiply(0, 10), 0);
});
// 测试 divide 函数
group('divide', () {
test('divide(10, 2) should return 5', () {
expect(divide(10, 2), 5);
});
test('divide(7, 3) should return 2 (integer division)', () {
expect(divide(7, 3), 2);
});
test('divide(5, 0) should return null', () {
expect(divide(5, 0), null);
});
});
});
}
3. 运行测试
在项目根目录执行以下命令运行所有测试:
bash
dart test
如果所有测试通过,会输出类似以下内容:
plaintext
00:00 +7: All tests passed!
如果某个测试失败(例如我们故意修改 add
函数为 return a - b
),会看到详细的错误信息:
plaintext
00:00 +0 -1: MathUtils add(2, 3) should return 5 [E] Expected: 5 Actual: -1 package:test_api/src/expect/expect.dart 135:31 expect test/math_utils_test.dart 8:7 main.<fn>.<fn> 00:00 +0 -1: Some tests failed.
4. 常用的匹配器(Matcher)
除了直接比较值,test
包还提供了丰富的匹配器来应对各种测试场景:
匹配器 | 用途 | 示例 |
---|---|---|
equals(value) |
检查是否相等(深层比较,适用于集合和对象) | expect([1,2], equals([1,2])) |
isA<Type>() |
检查类型 | expect(5, isA<int>()) |
isNull / isNotNull |
检查是否为 null | expect(divide(5,0), isNull) |
greaterThan(value) / lessThan(value) |
比较大小 | expect(5, greaterThan(3)) |
contains(value) |
检查集合是否包含元素 | expect([1,2,3], contains(2)) |
throwsA(matcher) |
检查是否抛出指定异常 | expect(() => int.parse('abc'), throwsA(isA<FormatException>())) |
示例:使用特殊匹配器增强测试
dart
test('divide should throw ArgumentError when b is negative', () {
// 测试当除数为负数时是否抛出异常(假设我们修改了divide函数)
expect(
() => divide(10, -2),
throwsA(isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('negative')
))
);
});
四、测试覆盖率查看:确保关键代码被测试
测试覆盖率(Test Coverage)是衡量测试完整性的指标,它表示被测试代码占总代码的比例。高覆盖率不一定意味着代码没有问题,但低覆盖率通常意味着存在未被测试的风险区域。
1. 安装 coverage 工具
要查看测试覆盖率,需要安装 coverage
包:
bash
dart pub global activate coverage
确保 ~/.pub-cache/bin
已添加到系统环境变量(否则无法直接运行 collect_coverage
命令)。
2. 生成覆盖率数据
执行以下命令运行测试并生成覆盖率数据:
bash
# 运行测试并收集覆盖率数据
dart test --coverage=coverage
# 转换覆盖率数据为LCOV格式(通用的覆盖率报告格式)
format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.dart_tool/package_config.json --report-on=lib
执行成功后,会在 coverage
文件夹中生成 lcov.info
文件。
3. 查看覆盖率报告
有多种方式可以查看 lcov.info
格式的覆盖率报告:
- 使用 VS Code 插件 :安装
Coverage Gutters
插件,右键点击lcov.info
文件,选择Watch
,即可在代码编辑器中直观看到哪些代码被覆盖(绿色),哪些未被覆盖(红色)。 - 生成 HTML 报告 :使用
genhtml
工具(需要先安装lcov
包):
bash
# Ubuntu/Debian 安装 lcov
sudo apt-get install lcov
# MacOS 安装 lcov(需要 Homebrew)
brew install lcov
# 生成 HTML 报告
genhtml coverage/lcov.info -o coverage/html
# 在浏览器中打开报告
open coverage/html/index.html # MacOS
xdg-open coverage/html/index.html # Linux
HTML 报告会展示每个文件的覆盖率百分比,以及具体哪些行未被测试覆盖。
4. 关键代码测试原则
高覆盖率是目标,但更重要的是覆盖关键代码和场景,以下是一些实用原则:
- 覆盖核心业务逻辑:优先测试处理数据、业务规则的核心函数,这些地方出错影响最大。
- 覆盖边界条件 :
- 数值类型:最大值、最小值、0、负数
- 字符串:空字符串、特殊字符、超长字符串
- 集合:空集合、单元素集合、超大集合
- 覆盖异常场景 :
- 无效输入(如除数为 0、null 参数)
- 网络错误、文件不存在等异常情况
- 避免过度测试 :
- 不需要测试简单的 getter/setter(除非有特殊逻辑)
- 不需要测试依赖的第三方库(假设它们已经被充分测试)
- 不需要为了追求 100% 覆盖率而测试明显不会出错的代码
示例:为 divide
函数补充边界测试
dart
test('divide with maximum integer values', () {
expect(divide(9223372036854775807, 1), 9223372036854775807); // int 最大值
expect(divide(-9223372036854775808, -1), 9223372036854775807); // int 最小值
});
五、高级测试技巧
1. 测试异步代码
Dart 中很多操作是异步的(如文件读写、网络请求),测试异步代码需要使用 async/await
:
dart
// 待测试的异步函数(lib/data_fetcher.dart)
Future<int> fetchData() async {
// 模拟网络请求
await Future.delayed(Duration(milliseconds: 100));
return 42;
}
// 测试代码(test/data_fetcher_test.dart)
import 'package:test/test.dart';
import 'package:my_test_project/data_fetcher.dart';
void main() {
test('fetchData should return 42', () async {
// 使用 async/await 处理异步
final result = await fetchData();
expect(result, 42);
});
}
2. 测试超时控制
对于可能挂起的异步测试,可以设置超时时间:
dart
test('fetchData should complete within 200ms', () async {
final result = await fetchData().timeout(Duration(milliseconds: 200));
expect(result, 42);
}, timeout: Timeout(Duration(milliseconds: 300))); // 测试整体超时
3. 使用 Mock 隔离依赖
当测试依赖外部系统(如数据库、API)时,应使用 Mock(模拟)对象隔离依赖,确保测试的稳定性。
首先添加 mockito
依赖:
dart
dev_dependencies:
test: ^1.24.0
mockito: ^5.4.0 # Mock 框架
build_runner: ^2.4.0 # 生成 Mock 代码需要
示例:使用 Mock 测试依赖 API 客户端的代码
dart
// lib/user_repository.dart
import 'user_api_client.dart';
class UserRepository {
final UserApiClient apiClient;
UserRepository(this.apiClient);
Future<String> getUserName(int id) async {
return await apiClient.fetchUserName(id);
}
}
// lib/user_api_client.dart
abstract class UserApiClient {
Future<String> fetchUserName(int id);
}
// 测试代码(test/user_repository_test.dart)
import 'package:test/test.dart';
import 'package:mockito/mockito.dart';
import 'package:my_test_project/user_repository.dart';
import 'package:my_test_project/user_api_client.dart';
// 生成 Mock 类
class MockUserApiClient extends Mock implements UserApiClient {}
void main() {
late UserRepository repository;
late MockUserApiClient mockApiClient;
// 在每个测试前初始化
setUp(() {
mockApiClient = MockUserApiClient();
repository = UserRepository(mockApiClient);
});
test('getUserName should return name from api client', () async {
// 配置 Mock 行为:当调用 fetchUserName(1) 时返回 "Alice"
when(mockApiClient.fetchUserName(1)).thenAnswer((_) async => "Alice");
// 测试 repository
final name = await repository.getUserName(1);
// 验证结果
expect(name, "Alice");
// 验证 Mock 方法被正确调用
verify(mockApiClient.fetchUserName(1)).called(1);
});
}
生成 Mock 代码:
bash
dart run build_runner build
4. 参数化测试
当多个测试用例逻辑相同仅输入输出不同时,可以使用参数化测试减少重复代码:
dart
import 'package:test/test.dart';
import 'package:my_test_project/math_utils.dart';
void main() {
group('add parameterized tests', () {
// 测试数据:输入a, 输入b, 预期结果
final testCases = [
{'a': 2, 'b': 3, 'expected': 5},
{'a': -1, 'b': 1, 'expected': 0},
{'a': 0, 'b': 0, 'expected': 0},
{'a': 100, 'b': -50, 'expected': 50},
];
for (final testCase in testCases) {
test(
'add(${testCase['a']}, ${testCase['b']}) should return ${testCase['expected']}',
() {
expect(
add(testCase['a'] as int, testCase['b'] as int),
testCase['expected'],
);
},
);
}
});
}
六、在 CI/CD 中集成测试
为了确保每次代码提交都能通过测试,应将测试集成到持续集成(CI)流程中。以 GitHub Actions 为例,创建 .github/workflows/test.yml
:
yaml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Dart
uses: dart-lang/setup-dart@v1
- name: Get dependencies
run: dart pub get
- name: Run tests
run: dart test
- name: Check code coverage
run: |
dart pub global activate coverage
dart test --coverage=coverage
format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.dart_tool/package_config.json --report-on=lib
这样,每次推送代码或创建 PR 时,GitHub 都会自动运行测试并报告结果。