Flutter Unit Test 理论篇

3,598

当项目复杂度达到一定程度后,往往容易出现各种逻辑漏洞,而且这些漏洞还可能在之后的代码修改中反反复复出现。因此,我们需要为项目撰写各种测试,帮助我们把控项目代码的稳定性和质量。根据测试对象的不同,我们一般会有面向逻辑代码的 Unit Test(单元测试)和面向软件界面的 UI Test(界面测试)。

单元测试,测试的对象是函数或者类,或者说是逻辑单元。一个符合预期的正确函数,每一个输入都会有对应正确的输出,这是单元测试的理论基础。基于此,我们通过向测试函数输入多个参数,并对输出的结果进行判断,就能够得出该函数是否符合预期的结论

编写一个 Unit Test

安装测试框架

引入 dart 测试框架 test 或使用 Flutter 脚手架默认提供的 flutter_test。 test 框架只包含 dart 测试所需的 API,适合纯 dart 项目。flutter_test 内部包含了 test 框架的所有 API,属于包含与被包含的关系,但 flutter_test 没有直接依赖 test 框架。我们一般直接选择 flutter_test 即可。

dev_dependencies:
  flutter_test:
    sdk: flutter

创建测试文件

通常测试文件应位于放置在 Flutter 应用或包的根目录下的 test 文件夹。测试文件通常以 _test.dart 命名,这是 test runner 寻找测试文件的惯例。如果我们用命令执行某个文件夹下的所有测试文件,那么命令只会执行 _test 结尾的文件。 创建完成后,文件目录结构如下:

.
├── lib
│   ├── counter.dart
├── test
│   ├── counter_test.dart

编写逻辑功能代码

class Counter {
  int value = 0;

  void increment() => value++;

  void decrement() => value--;
}

编写测试代码,熟悉测试框架 API

// 跳过该测试文件,用于暂时跳过某个测试而不警报
@Skip('currently failing')

// 默认每个 test 将在停止活动后30秒后报超时错误,此处可以手动设置超时时间
@Timeout(const Duration(seconds: 45))

void main() {
  final counter = CounterProvider();

  // group 定义了一组测试,内部可以包含多个 test。一般我们可以把一个功能单元作为一组,内部则存放多个测试边界,比如参数正确的情况、参数错误的情况。
  group(
    'Counter',
    () {
      // 每个测试用例需要使用 test 包裹起来,创造一个测试环境。test 可以传入平台、超时等多种测试环境参数。
      test('value should start at 0', () {
        // expect 是最基本的方法,用于判断前后两个值是否相同
        expect(counter.value, 0);
        counter.increment();
        expect(counter.value, 1);

        // expect 的第二个参数可以传递各种 matcher ,用于判断不同的条件
        // 比如 allOf ,判断是否满足多种 matcher
        // contains、isNot、startsWith、endsWith
        expect('foo,bar,baz', allOf([contains('foo'), isNot(startsWith('bar')), endsWith('baz')]));

        // 或者匹配一个系统内置的异常
        expect(() => int.parse('X'), throwsFormatException);

        // 匹配一个自定义异常
        expect(Future.error('oh no'), throwsA(equals('oh no')));
      },
          // 跳过测试该 test
        skip: "the algorithm isn't quite right",
          // 单个 test 和 group 也可以设置超时实际
          timeout: Timeout(Duration(seconds: 45)));

      // 异步方法也没有问题
      test('value should be incremented', () async {
        var value = await Future.value(10);
        expect(value, equals(10));
        // 异步 throw 同样处理
        expect(Future.error('oh no'), throwsA(equals('oh no')));
      });
    },
    // 跳过测试该 group
      skip: "the algorithm isn't quite right"
  );

  // setUp 会在每个 test 运行之前运行,一般用来初始化共享代码
  setUp(() async {
    counter.reset();
  });
  // tearDown 会在每个 test 运行之后允许,一般用来清理共享代码,以免影响其他测试 case
  tearDown(() async {
    counter.reset();
  });
}

test 框架的 API 并不多,主要就是用于匹配结果的 macher 比较多。可以点击这里查看所有 macher

执行测试代码

IDE: 右键测试文件 counter_test.dart ,点击 run。或直接点击代码左侧的 Run 按钮。 Terminal:

flutter test test/counter_test.dart

API

Matcher List

Unit test 中用到的 API 并不多,比较复杂的可能就是用于验证匹配的 matcher 。这些 matcher 繁复且数量众多,但熟悉这些 API 才能帮助我们写出更好的 test case。

equals

用于比对两者是否相等,比较的对象可以是基本类型,也可以是 matcher。

// 如果比较对象(第二个参数)是基本类型,那么 expect 会将其包在 equals 中
expect(1, 1);
expect(1, equals(1));
expect('foo', equals(contains('foo')));

核心类

isEmpty

isNotEmpty

isNull

isNotNull

isTrue

isFalse

isNaN

isNotNaN

Same

比较两者是不是同一个实例

expect(counter.value, same(vlue));

anything

匹配任何值

returnsNormally

hasLength

判断对象的长度是否符合要求

expect('Foo', hasLength(3));
expect('Foo', hasLength(greaterThan(3)));

contains

比对的值是否包含

expect('Foo', contains('F'));

isIn

比对的值是否被包含

expect('Foo', isIn('Food'));

predicate

通过 block 手动判断比对的值是否正确

expect('foo', predicate((value) => value is String));

比较类

greaterThan

greaterThanOrEqualTo

lessThan

lessThanOrEqualTo

isZero

isNonZero

isPositive

isNonPositive

isNegative

isNonNegative

expect('Foo', hasLength(greaterThan(3)));

错误类

基本是对内置的错误类型的包装

isArgumentError

isCastError

isConcurrentModificationError

isCyclicInitializationError

isException

isFormatException

isNoSuchMethodError

isNullThrownError

isRangeError

isStateError

isUnimplementedError

isUnsupportedError

类型类

isA

判断类型是否一致

expect('Foo', isA<String>());

List 类 比对对象为可迭代类,例如 List 等

everyElement

每个元素都匹配

expect(['foo', 'hoo'], everyElement(contains('oo')))

anyElement

只需要一个元素匹配

expect(['foo', 'hoo'], anyElement(contains('oo')))

orderedEquals

顺序、元素完全相等

unorderedEquals

元素完全相等,顺序可以不一致

unorderedMatches

元素需要匹配,顺序可以不一致

pairwiseCompare

containsAll

Map 类

匹配对象为 map

containsValue

containsPair

数字类

用于判断匹配对象是否在范围内

closeTo

inInclusiveRange

inExclusiveRange

inOpenClosedRange

inClosedOpenRange

字符串类 比对对象为 String

equalsIgnoringCase

不区分大小写比较

equalsIgnoringWhitespace

忽略空格字符

startsWith

endsWith

stringContainsInOrder

是否包含 substring,并且顺序一致

expect('abcdefghijklmnopqrstuvwxyz', stringContainsInOrder(["a", "e", "i", "o", "u"]))

matches

匹配正则表达式

collapseWhitespace

规则会将多个空格变为一个空格来匹配

expect('abc    ', collapseWhitespace('abc '))

操作符类

类似于 ! || &&,用于匹配多个 matcher

expect('foo,bar,baz', allOf([contains('foo'), isNot(startsWith('bar')), endsWith('baz')]));

isNot

allOf

anyOf

高阶

内部实现

expect 主要做的是外围的准备工作,比如判断作用域是否在 test block中,是否被 skip 。它还会判断 matcher 的类型,将基本类型包装在 equals 内等。

test 框架内部最重要的逻辑是其实不是 expect,而是 matcher,因为 macher 承载了所有判断逻辑。

Matcher 是一个抽象类,内部最重要的一个方法就是 bool matches(item, Map matchState),用于判断 item 是否符合条件。item 是我们拿来比对的值,但 matchState 不是比对的基准,它只是一个用于记录比对结果的 Map,用于 debug 和打印错误。

Matcher 的派生类主要可以分为4类,分别为类型相关 TypeMatcher、类型无关 _OrderingMatcher、嵌套递归 _DeepMatcher、异步 AsyncMatcher。

TypeMatcher 只实现了一个方法,就是判断类型是否一致。他的衍生抽象类,额外增加了一个方法,用于验证两个值是否相等。它的衍生累就是具体类型(String、num)的比对类,他们只需要实现各自类型的比对逻辑即可。

另一种直接继承自 Marcher 同时又与类型不直接相关的 Matcher,例如 _OrderingMatcher。它用于实现 greaterThanOrEqualTo、lessThan 这类比较的场景。

我们知道 Matcher 是支持像 allOf([contains('foo'), isNot(startsWith('bar')), endsWith('baz')]) 这样的嵌套使用的。_DeepMatcher 会递归解析和比对 Matcher 。

最后一个是用于处理异步比对的 Matcher。它会先拿到异步方法或值的结果,再与结果进行比对。

Mock 数据

当我们编写 Unit Test 的时候,常常会遇到一些不可靠场景,例如网络请求、数据库、其他框架提供的方法和数据。这会带来几个问题, 我们以网络请求为例:

  1. 服务端提供的 API 正确与否不在 App 单元测试的范围内,服务端的 API 应该由服务端来完成测试和保证准确性。因此,测试的过程中涉及到使用此类数据或方法的地方,应该使用 mock 数据而非真实数据,否则 App 的 Unit Test 结果会受大量不确定性因素的影响,得出的结论也无法评估 App 本身的质量。

  2. 访问线上服务或数据库会拖慢测试执行效率。

  3. 我们在测试的过程中需要创造一些边界条件,以测试代码的健壮性。例如各种网络错误、超时等。使用线上真实的 web 服务或数据库来测试很难覆盖全所有可能成功或失败的场景。因此我们需要能够手动控制网络请求的返回结果。

我们可以通过继承或实现协议的方式重写这些方法的返回值以达到这样的效果,不过通过 Mockito ,我们可以更加方便的实现对方法和变量的 mock ,根据测试用例人为控制结果。

// 定义 cat
class Cat {
 bool eatFood(String food, {bool hungry}) => true;
 Future<bool> sleep() => Future.value(true);
 int age = 6;
}
// Mock cat,此时 MockCat 已经获得了 cat 的所有方法和属性,只不过它获得的只有空方法(方法签名)和属性名,并没有方法的实现和属性的值。
class MockCat extends Mock implements Cat {}
void main() {
 // 创建 mock 实体
 final cat = MockCat();
 group('test cat', () {
  test('', () {
   // 可以调用 mock 类的方法,但是由于方法没有实现,所以返回值是 null
   expect(cat.eatFood(any), null);
   expect(cat.eatFood('fish'), null);
   // 通过 Mockito 提供的 API,我们设定了,当参数为 fish 时, sound 方法的返回值为 true
   when(cat.eatFood('fish')).thenReturn(true);
   // 之后我们调用 mock 类的 sound 方法就会返回设定的值
   expect(cat.eatFood('fish'), true);
   // 参数可以使用 argument matcher,匹配多种参数,具体参考 ArgMatcher
   when(cat.eatFood(argThat(startsWith("dry")))).thenReturn(false);
   // 如果参数是命名参数,需要使用 anyNamed 指定参数名,因为命名参数是没有顺序的,mock 框架无法判断出参数对应的实际参数
   when(cat.eatFood(any, hungry: anyNamed('hungry'))).thenReturn(true);
   // 如果要让方法返回异步数据,不能用 thenReturn,要使用 thenAnswer 返回一个 block
   when(cat.sleep()).thenAnswer((_) => Future.value(true));
   // 同样可以对变量进行mock
   when(cat.age).thenReturn(9);
   expect(cat.age, 9);
   // 验证使用调用过 cat.sound('fish'),如果之前没有调用过,报错
   verify(cat.eatFood('fish'));
   // 验证方法调用次数
   verify(cat.eatFood('fish')).called(2);
   // 同样支持 matcher
   verify(cat.eatFood('fish')).called(greaterThan(1));
   // 验证是否未调用过
   verifyNever(cat.eatFood(any));
   // 验证调用顺序
   verifyInOrder([cat.eatFood("Milk"), cat.eatFood('fish'), cat.eatFood("Fish")]);
   // 验证cat没有被调用过任何方法或参数
   verifyZeroInteractions(cat);
   // 验证cat之后有被调用过任何方法或参数
   verifyNoMoreInteractions(cat);
   // 重制 mock 类
   reset(cat);
  });
 });
}
// 继承 Fake 并重写对象的方法是 mockito 提供的另外一种 mock 类的方式,你可以对 mock 对象的方法做额外的实现
// Mock 和 Fake 两种方式不能混用,即继承了 Mock 的对象不能再重写原始类方法,而继承 Fake 的对象不能使用 when mock 方法的返回值。
class FakeCat extends Fake implements Cat {
 @override
 bool eatFood(String food, {bool hungry}) {
  print('Fake eat $food');
  return true;
 }
}

最佳实践

我们 Mock 出的对象,肯定不是我们需要直接测试的对象,因为 Mock 的返回值都是我们手动设定的,没有测试的必要。我们测试的都是会间接调用 Mock 对象的类和方法,因此官方建议,我们在 Mock 完对象的方法或属性后,需要调用 verity 方法验证 Mock 方法或变量是否被调用。如果我们测试的对象完全没有使用到 Mock 对象的方法或变量,那么我们的 Mock 就变得毫无意义。

原理

Mockito 的原理很简单,mock 类 implements 了原始类,获得了原始类的方法和属性定义,但都没有实现,因此调用 mock 类默认会走类的 noSuchMethod 方法。而继承的 Mock 类就是实现了这个 noSuchMethod方法,将noSuchMethod的返回值替换成我们通过 when 设定的值。

pub.flutter-io.cn/packages/mo…

代码覆盖率

官方现在的代码覆盖率暂时不支持 Flutter。

错误处理

Error: Not found: 'dart:ui'

由于我们是 Flutter 项目,需要使用 flutter test 启动测试,而非 pub run test。后者会将代码放入 Dart VM 执行,而 Dart VM 自然是没有 Flutter 的运行环境的。

对于 IntelliJ 或 Android Studio,可以查看 IDE 的Run/Debug Configuration,我们的测试文件不应该出现在 Dart Command Line App 中,Flutter Test 才是它的归宿。

局限

Unit Test 对测试代码有一定要求,也就是代码的可测性(testable)。对于纯逻辑的方法或类,Unit test 可以很好的对其进行测试并且可以达到一个客观的覆盖率。例如一个判断字符串是否符合要求的方法,入参是 String,返回值则为 Bool。如果你使用 Provider 类似的框架,那么 provider 也是理想的测试对象。

不过,随着项目的复杂度上升和代码的不规范,我们往往会在纯逻辑方法或类中引入 UI 逻辑,最常见的就是 BuildContext,用于弹出 toast 或跳转页面。

此外,对于需要 mock 的逻辑,如何注入到现有项目中也是一个问题。在没有引入 TDD 的项目中,往往在设计 API 的时候不会考虑到依赖注入等逻辑。这就为我们之后 mock 逻辑的替换造成困难。

因此,了解并熟悉 Unit Test 框架和 API 仅仅是第一步,在下一篇实操篇中,我们会一步一步来改造我们项目的代码,逐步将原本不可测的代码变得更加 testable 。