dart学习第 23 节: 单元测试入门 —— 保证代码质量

在前几节课中,我们学习了性能优化的基础知识,掌握了从代码层面提升程序效率的技巧。今天我们将聚焦于单元测试------ 这是保证代码质量的关键手段,能够帮助我们在开发早期发现问题,减少线上故障,同时让代码更易于维护和重构。

一、单元测试基础:为什么需要单元测试?

单元测试(Unit Testing)是对软件中最小可测试单元(通常是函数、方法或类)进行的测试。它的核心价值在于:

  1. 提前发现问题:在开发阶段就能发现代码中的逻辑错误,避免问题被带到生产环境。
  2. 保障重构安全:当修改或重构代码时,单元测试可以快速验证功能是否依然正常。
  3. 文档作用:测试用例本身就是一种代码文档,清晰展示了函数的预期行为。
  4. 促进模块化设计:编写可测试的代码会促使我们写出更松散耦合、职责单一的模块。

在 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. 关键代码测试原则

高覆盖率是目标,但更重要的是覆盖关键代码和场景,以下是一些实用原则:

  1. 覆盖核心业务逻辑:优先测试处理数据、业务规则的核心函数,这些地方出错影响最大。
  2. 覆盖边界条件
    • 数值类型:最大值、最小值、0、负数
    • 字符串:空字符串、特殊字符、超长字符串
    • 集合:空集合、单元素集合、超大集合
  3. 覆盖异常场景
    • 无效输入(如除数为 0、null 参数)
    • 网络错误、文件不存在等异常情况
  4. 避免过度测试
    • 不需要测试简单的 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 都会自动运行测试并报告结果。

相关推荐
来来走走7 小时前
Flutter开发 了解Scaffold
android·开发语言·flutter
zeqinjie12 小时前
Flutter 使用 AI Cursor 快速完成一个图表封装【提效】
前端·flutter
一念之间lq13 小时前
学习Flutter-Flutter项目如何运行
flutter
叽哥13 小时前
dart学习第 22 节:性能优化基础 —— 从代码层面提速
flutter·dart
牛巴粉带走19 小时前
Flutter 构建失败:watchOS Target 类型无法识别的解决记录
flutter·ios·apple watch
tbit20 小时前
Flutter Provider 用法总结(更新中...)
android·flutter
TralyFang20 小时前
Flutter listview的复用与原生有什么区别
flutter
无知的前端21 小时前
一文读懂 - Flutter (Dart) 对象内存分配深度解析
flutter·性能优化
来来走走1 天前
Flutter开发 MaterrialApp基本属性介绍
android·flutter