现在位置: 首页 > Dart 教程 > 正文

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):

// 文件路径: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):

// 文件路径: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验证 nullexpect(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 的实际应用:

import 'package:test/test.dart';

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!

测试异常

实例

import 'package:test/test.dart';

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() 用于将相关的测试用例组织在一起,让测试结构更清晰。

实例

import 'package:test/test.dart';

// 被测试的函数
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 之后运行。

它们用于准备测试环境和清理资源。

实例

import 'package:test/test.dart';

// 模拟一个需要初始化和清理的类
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 完成。

实例

import 'package:test/test.dart';

// 异步函数:模拟网络请求
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 开发中。