断言(Assertion)与匹配器 (Matchers)

Dart 测试的核心围绕断言(Assertion)展开。 我们通常使用 expect 函数来执行校验,其基本语法如下:

  expect(dynamic actual, dynamic matcher)

以之前的 expect(f.num, 1) 为例:该语句断言变量 f.num(实际值 actual)等于 1(期望值 matcher)。

其中,matcher(匹配器)是断言机制中最灵活、最强大的部分。它不仅能进行简单的值比较,还能处理复杂的逻辑校验。本文将结合示例简要介绍下常用的各类匹配器:

本文最后还将介绍如何自定义匹配器

基础匹配器 (Basic Matchers)

  • equals(v): 检查值是否相等(通常直接写值即可,如 expect(a, 10)
  • isTrue / isFalse: 检查布尔值
  • isNull / isNotNull: 检查是否为 Null
  • same(obj): 检查是否为同一个对象实例(内存地址相同)
// m_basic_test.dart
import 'package:test/test.dart';

void main() {
  group('基础匹配器用法演示', () {
    // equals(v) - 检查值是否相等
    test('equals 匹配器', () {
      var name = 'Dart';
      // 实际上 expect(name, 'Dart') 内部也会自动调用 equals
      expect(name, equals('Dart'));
      expect(1 + 1, equals(2));

      // 对于集合,equals 会深度比较内容
      expect([1, 2], equals([1, 2]));
    });

    // isTrue / isFalse - 检查布尔状态
    test('布尔匹配器', () {
      var isVisible = true;
      var hasError = false;

      expect(isVisible, isTrue);
      expect(hasError, isFalse);
    });

    // isNull / isNotNull - 检查空值状态
    test('Null 匹配器', () {
      String? errorMessage;
      expect(errorMessage, isNull);

      errorMessage = '404 Not Found';
      expect(errorMessage, isNotNull);
    });

    // same(v) - 检查是否为同一个实例(内存地址)
    test('same 匹配器 (身份验证)', () {
      final listA = [1, 2, 3];
      final listB = [1, 2, 3];
      final listC = listA;

      // 重点:listA 和 listB 内容相同,但不是同一个对象
      expect(listA, equals(listB)); // 通过:值相等
      expect(listA, isNot(same(listB))); // 通过:内存地址不同

      // listC 是 listA 的引用
      expect(listA, same(listC)); // 通过:指向同一个实例
    });
  });
}

数值匹配器 (Numeric Matchers)

处理数字时,除了相等,我们还经常需要范围判断。

  • greaterThan(n) / lessThan(n): 大于 / 小于。
  • greaterThanOrEqualTo(n) / lessThanOrEqualTo(n): 大于等于 / 小于等于。
  • closeTo(value, delta): 用于浮点数比较。由于精度问题,直接比较浮点数容易出错。
  • isNegative / isZero / isPositive: 是否负数 / 0 / 正数
  • isNonNegative / isNonPositive: 是否非负数 / 非正数
// m_numeric_test.dart
import 'package:test/test.dart';

void main() {
  group('数值匹配器 (Numeric Matchers) 演示', () {
    
    test('大小比较', () {
      num score = 85;

      // 严格大于/小于
      expect(score, greaterThan(80));
      expect(score, lessThan(100));

      // 包含边界的小于等于/大于等于
      expect(score, greaterThanOrEqualTo(85));
      expect(score, lessThanOrEqualTo(85));
    });

    test('正负号与特殊值判断', () {
      expect(-3, isNegative);     // 是否为负数
      expect(0, isZero);          // 是否为 0
      expect(1, isPositive);      // 是否为正数
      expect(10, isNonNegative);  // 是否为非负数 (>= 0)
      expect(-5, isNonPositive);  // 是否为非正数 (<= 0)
    });

    test('浮点数精度匹配 (closeTo)', () {
      /* 由于计算机二进制表示的原因,0.1 + 0.2 并不完全等于 0.3。
       在测试中直接使用 equals(0.3) 可能会失败。
      */
      double result = 0.1 + 0.2;

      // 语法:closeTo(期望值, 容差范围)
      // 只要结果在 0.3 ± 0.0001 之间即视为通过
      expect(result, closeTo(0.3, 0.0001));
    });

    test('组合范围检查', () {
      num temperature = 25;

      // 使用 allOf 检查数值是否在某个区间内 (20 < temp < 30)
      expect(temperature, allOf([
        greaterThan(20),
        lessThan(30)
      ]));
    });

  });
}

字符串匹配器(String Matchers)

字符串匹配器(String Matchers)专门用于校验文本内容。它们允许你检查字符串的部分特征(如开头、结尾、是否包含)或者使用正则表达式进行复杂的模式匹配。

  • startsWith('prefix') / endsWith('suffix'): 前缀/后缀。
  • contains('substring'): 包含子串。
  • matches(RegExp(...)): 使用正则表达式匹配。
  • isEmpty / isNotEmpty: 空/非空字符串
  • equalsIgnoringCase: 判断字符串相等时忽略大小写
// m_string_test.dart
import 'package:test/test.dart';

void main() {
  group('字符串匹配器 (String Matchers) 演示', () {
    
    test('位置与包含匹配', () {
      var report = 'Error: Battery low on device_01';

      // 检查是否以特定字符串开头/结尾
      expect(report, startsWith('Error'));
      expect(report, endsWith('device_01'));

      // 检查是否包含子串
      expect(report, contains('Battery'));
      expect(report, contains('low'));
    });

    test('忽略大小写与空白', () {
      var email = 'User@Example.Com';

      // equalsIgnoringCase 检查内容是否一致,但不区分大小写
      expect(email, equalsIgnoringCase('user@example.com'));

      // collapseWhitespace 匹配时会将多个空格、换行符压缩为一个空格
      var multiLine = 'Hello    \n  World';
      expect(multiLine, equalsIgnoringWhitespace('Hello World'));
    });

    test('正则表达式匹配 (matches)', () {
      var id = 'ABC-12345';

      // 使用 matches 配合正则表达式进行复杂校验
      // 校验规则:三个大写字母 + 短横线 + 五位数字
      expect(id, matches(RegExp(r'^[A-Z]{3}-\d{5}$')));
    });

    test('空字符串校验', () {
      var emptyStr = '';
      var spaceStr = '   ';

      expect(emptyStr, isEmpty);
      expect(spaceStr, isNotEmpty);
      
      // 只有全是空格的字符串可以这样检查其内容
      expect(spaceStr.trim(), isEmpty);
    });

  });
}

集合匹配器 (Collection Matchers)

集合匹配器(Collection Matchers)不仅能检查 List、Set 或 Map 是否包含某个元素,还能校验集合的长度、顺序以及元素类型。

  • 内容检查
    • contains(element): 包含某个元素。
    • isIn(iterable): 实际值是否在给定的集合中。
  • 长度检查
    • hasLength(n): 检查集合、字符串的长度。
  • 列表顺序
    • containsAll([a, b]): 包含所有指定元素,顺序不限。
    • containsAllInOrder([a, b]): 包含所有元素,且顺序必须一致。
  • Map 专用
    • containsValue(v): 检查值
    • containsPair(key, valueOrMatcher): 检查键值对。
// m_collection_test.dart
import 'package:test/test.dart';

void main() {
  group('集合匹配器 (Collection Matchers) 演示', () {
    test('内容与长度检查', () {
      var fruits = ['apple', 'banana', 'cherry'];

      expect(fruits, isNotEmpty);
      expect(fruits, hasLength(3));
      expect(fruits, contains('banana'));
    });

    test('列表元素匹配 (List)', () {
      var numbers = [1, 2, 3, 4, 5];

      // 包含所有指定元素,顺序不限
      expect(numbers, containsAll([3, 1, 5]));

      // 包含所有指定元素,且顺序必须一致
      expect(numbers, containsAllInOrder([2, 3, 4]));

      // 检查列表中的每一个元素是否都符合某个条件
      // 比如:所有数字都大于 0
      expect(numbers, everyElement(greaterThan(0)));

      // 检查列表中是否有【至少一个】元素符合条件
      expect(numbers, anyElement(greaterThan(4)));
    });

    test('Map 键值对检查', () {
      var config = {
        'id': 101,
        'status': 'active',
        'tags': ['admin', 'web'],
      };

      expect(config, contains('status')); // contains key
      expect(config, containsValue('active'));

      expect(config, containsPair('id', 101));
    });

    test('组合逻辑校验', () {
      var users = ['Alice', 'Bob', 'Charlie'];

      // 检查集合是否:不为空 且 长度小于 5 且 包含 'Alice'
      expect(
        users,
        allOf([isNotEmpty, hasLength(lessThan(5)), contains('Alice')]),
      );
    });
  });
}

类型匹配器 (Type Matchers)

类型匹配器(Type Matchers) 用于验证对象的类、接口或具体的子类型。这在处理多态、依赖注入或解析 API 返回的动态数据时至关重要。

  • isA<T>(): 检查类型
  • isA<T>().having(feature, description, matcher) : 在确认类型后,直接对该类型的属性进行链式校验
    • 第一个参数:特征提取器(Feature extractor),通常是一个匿名函数。
    • 第二个参数:描述文字(Description),当测试失败时,会显示在错误报告中。
    • 第三个参数:期望值或另一个匹配器。
// m_type_test.dart
import 'package:test/test.dart';

void main() {
  test('类型校验', () {
    var name = 'Kitty';
    var numbers = [1, 2, 3];

    expect(name, isA<String>());
    expect(numbers, isA<List<int>>());

    // 也可以检查接口或父类
    expect(numbers, isA<Iterable>());
  });

  test('检查对象类型及其属性', () {
    var admin = User('Alice', 99);

    expect(
      admin,
      isA<User>()
          .having((u) => u.name, 'name', 'Alice') // 检查名字
          .having((u) => u.level, 'level', greaterThan(90)), // 检查等级
    );
  });
}

class User {
  final String name;
  final int level;
  User(this.name, this.level);
}

为了方便使用,test 库提供了一些预定义的类型匹配器。

匹配器等效于
isListisA<List>()
isMapisA<Map>()
isArgumentErrorisA<ArgumentError>()
isExceptionisA<Exception>()

异常匹配器 (Error Matchers)

检查代码是否会抛出异常是健壮性测试的重要组成部分。为了测试异常,expect函数的第一个参数必须是一个闭包(匿名函数),这样测试框架才能捕获执行过程中抛出的错误。

  • throwsException: 抛出任何异常。
  • throwsA<T>: 抛出特定类型的异常。
// m_ex_test.dart
import 'package:test/test.dart';

void main() {
  group('异常匹配器 (Error Matchers) 演示', () {
    // 异常检查
    test('捕获任意异常', () {
      // 必须包装在 () => 中
      expect(() => throw Exception('oops'), throwsException);
    });

    // 检查特定的异常类型 (使用 throwsA)
    test('捕获特定类型的错误', () {
      // 检查是否抛出了 StateError
      expect(() => List.empty().first, throwsA(isA<StateError>()));

      // 检查是否抛出了 ArgumentError
      expect(() => int.parse('not_a_number'), throwsA(isA<FormatException>()));
    });

    // 检查异常的具体内容
    test('检查异常消息内容', () {
      expect(
        () => throw ArgumentError('invalid id'),
        throwsA(
          isA<ArgumentError>().having(
            (e) => e.message, // 特性提取器 (Property getter)
            'message', // 描述(用于报错显示)
            contains('invalid'), // 匹配器
          ),
        ),
      );
    });

    // 断言错误 (AssertionError)
    test('捕获断言失败', () {
      expect(() {
        assert(1 == 2, 'Logic error');
      }, throwsA(isA<AssertionError>()));
    });

    // 异步异常处理
    test('捕获 Future 中的异常', () async {
      Future<void> asyncError() async {
        await Future.delayed(Duration(milliseconds: 10));
        throw Exception('async error');
      }

      await expectLater(asyncError(), throwsA(isA<Exception>()));
    });
  });
}

逻辑组合 (Logical Combinators)

逻辑组合匹配器 (Logical Combinators) 允许你将多个基础匹配器组合成复杂的校验逻辑,就像在代码中使用 && (AND)、|| (OR) 和 ! (NOT) 一样。

  • allOf([m1, m2]): 必须满足所有匹配器(逻辑与)。
  • anyOf([m1, m2]): 满足其中之一即可(逻辑或)。
  • isNot(matcher): 取反
// m_logic_test.dart
import 'package:test/test.dart';

void main() {
  test('allOf 演示:数值范围与特征', () {
    var score = 64;

    // 检查数值是否:大于 80 且 小于 100 且 是偶数
    expect(
      score,
      allOf([
        greaterThan(60),
        lessThan(100),
        predicate<int>((n) => n % 2 == 0, 'is even'),
      ]),
    );
  });

  test('anyOf 演示:多选一校验', () {
    var status = 'loading';

    // 状态必须是 'success'、'error' 或 'loading' 中的一种
    expect(
      status,
      anyOf([equals('success'), equals('error'), 'loading']),
    );

    // 也可以混合不同类型的匹配器
    var value = 5;
    expect(value, anyOf([isNegative, isZero, 5]));
  });

  test('isNot 演示:排除特定状态', () {
    var token = 'secure_token_123';

    expect(token, isNot(isEmpty));
    expect(token, isNot(startsWith('debug_')));
    expect(token, isNot(contains('password')));
  });
}

自定义匹配器

当现有的匹配器无法满足复杂的业务需求时,我们可以通过 继承 MatcherCustomMatcher 来自定义匹配器。

通过继承 Matcher 来自定义匹配器

这里要实现一个自定义匹配器,需要重写三个核心方法:

  • matches(item, matchState): 核心逻辑。返回 true 表示匹配成功。
  • describe(Description description): 描述“期望值”是什么。
  • describeMismatch(...) (可选): 当匹配失败时,描述“实际值”为什么不对。

下面例子中的自定义匹配器 isEmail ,用来校验一个字符串是否为 Email 格式。

// m_email_test.dart
import 'package:test/test.dart';

void main() {
  test('自定义匹配器演示', () {
    expect('hello@example.com', isEmail);
    expect('invalid-email', isNot(isEmail));
  });
}

// 定义匹配器类
class _IsEmail extends Matcher {
  const _IsEmail();

  @override
  bool matches(dynamic item, Map matchState) {
    if (item is! String) return false;
    // 简单的正则示例
    return RegExp(r"^[a-zA-Z0-9.]+@[a-zA-Z0-9]+\.[a-zA-Z]+$").hasMatch(item);
  }

  @override
  Description describe(Description description) {
    return description.add('是一个合法的 Email 地址');
  }

  @override
  Description describeMismatch(
    dynamic item,
    Description mismatchDescription,
    Map matchState,
    bool verbose,
  ) {
    return mismatchDescription
        .add('实际值是 ')
        .addDescriptionOf(item)
        .add(',不符合邮箱格式');
  }
}

// 封装成一个全局常量
const Matcher isEmail = _IsEmail();

通过继承 CustomMatcher 来自定义匹配器

如果只需要对某个类的属性做检查,继承 CustomMatcher 会更简单。

// m_adult_test.dart
import 'package:test/test.dart';

void main() {
  test('自定义匹配器演示(CustomMatcher)', () {
    expect(User('Sally', 18), isAdult);
    expect(User('Andrew', 12), isNotAdult);
  });
}

final isAdult = _IsAdult();
final isNotAdult = isNot(isAdult);

class _IsAdult extends CustomMatcher {
  _IsAdult() : super("age>=18", "age", greaterThanOrEqualTo(18));

  @override
  Object? featureValueOf(actual) => (actual as User).age;
}

class User {
  String name = '';
  int age = 0;
  User(this.name, this.age);
}

predicate

当你不想为了一个简单的逻辑去写一个 CustomMatcher 类时,predicate 就是你的最佳选择。

  test('使用 predicate 校验 age', () {
    expect(User('Sally', 18), predicate<User>((u) => u.age >= 18, 'age>=18'));
  });

Reference