[译]Dart mock库mockito

1,105 阅读7分钟

「这是我参与2022首次更文挑战的第19天,活动详情查看:2022首次更文挑战」。

本文翻译自pub: mockito | Dart Package (flutter-io.cn)

译时版本: mockito 5.0.17


mockito

Dart 的 Mock 库,受 Mockito 启发。

我们来创建 mock

Mockito 5.0.0 支持 Dart2.12 的 非空 语言特性,主要是代码生成。

要使用 Mockito 生成的 Mock 类,在 pubspec.yaml 文件中的 dev_dependencies 下添加 build_runner 依赖。如 build_runner: ^1.11.0 之类的包。

作为代码生成 API 的替代方案,查看 NULL_SAFETY_README

让我们以一个 Dart 库开始, cat.dart

import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'cat.mocks.dart';

// 真实的类
class Cat {
  String sound() => "Meow";
  bool eatFood(String food, {bool? hungry}) => true;
  Future<void> chew() async => print("Chewing...");
  int walk(List<String> places) => 7;
  void sleep() {}
  void hunt(String place, String prey) {}
  int lives = 9;
}

// 注解用于创建 cat.mocks.dart 库和 MockCat 类。
@GenerateMocks([Cat])
void main() {
  // 创建 mock 对象
  var cat = MockCat();
}

通过使用 @GenerateMocks 注解库元素(例如,测试文件的 main 函数、或类),可以指示 Mockito 的代码生成器为每个列出的真正类在一个新的库里生成一个 mock 类。

下一步是运行 build_runner 以生成这个新的库。

dart run build_runner build

build_runner 会基于包含 @GenerateMocks 注解的文件名生成一个文件。在上面的 cat.dart 示例中,我们导入了生成的库作为 cat.mocks.dart

生成的 mock 类,MockCat ,继承了 Mockito 的 Mock 类并实现了 Cat 类, 给予我们一个支持插桩和验证的类。

让我们验证一些行为

// 使用 mock 对象交互
cat.sound();
// 验证交互
verify(cat.sound());

一旦创建, mock 实例会记住所有的交互。然后你可以选择性地 verify(验证)(或) verifyInOrder(按顺序验证) (或) verifyNever) (永不验证))感兴趣的交互。

一些插桩如何?

// 在交互前插桩一个 mock 方法
when(cat.sound()).thenReturn("Purr");
expect(cat.sound(), "Purr");

// 可以再次调用
expect(cat.sound(), "Purr");

// 让我们改变一下插桩
when(cat.sound()).thenReturn("Meow");
expect(cat.sound(), "Meow");

// 可以对 getter 插桩
when(cat.lives).thenReturn(9);
expect(cat.lives, 9);

// 可以插桩一个方法用来 throw
when(cat.lives).thenThrow(RangeError('Boo'));
expect(() => cat.lives, throwsRangeError);

// 可以在调用时处理响应
var responses = ["Purr", "Meow"];
when(cat.sound()).thenAnswer((_) => responses.removeAt(0));
expect(cat.sound(), "Purr");
expect(cat.sound(), "Meow");

when、 thenReturn、 thenAnswer 和 thenThrow API 提供了一个覆写该行为的插桩机制。一旦插桩,无论调用多少次,方法会总是返回插桩的值。 如果一个方法调用匹配多个插桩,最后一个被声明的会被使用。 只对 mock 类的方法进行插桩和验证没有意义;在本例中, MockCat 的一个实例会被使用,而不是 Cat 的一个实例。

异步插桩的简单说明

使用 thenReturn 返回 Future 或 Stream 会抛出一个 ArgumentError  这是因为它会导致不可预知的行为。例如:

  • 如果方法在一个不同的区域(并非消费 Future )的区域被插桩,就会发生不可预知的行为。
  • 如果方法被插桩用于返回一个失败的 Future 或 Stream ,并且它没有在相同的运行循环中被消费,那它有可能会被全局的异常处理器消费,而不是应由它消费的异常处理器。

取而代之,使用 thenAnswer 来插桩方法返回一个 Future 或 Stream

// BAD
when(mock.methodThatReturnsAFuture())
    .thenReturn(Future.value('Stub'));
when(mock.methodThatReturnsAStream())
    .thenReturn(Stream.fromIterable(['Stub']));

// GOOD
when(mock.methodThatReturnsAFuture())
    .thenAnswer((_) async => 'Stub');
when(mock.methodThatReturnsAStream())
    .thenAnswer((_) => Stream.fromIterable(['Stub']));

如果,由于一些原因,你期望 thenReturn 的行为,可以返回一个预定义的实例。

// 使用上面的方法,除非能确定要事先创建 Future 。
final future = Future.value('Stub');
when(mock.methodThatReturnsAFuture()).thenAnswer((_) => future);

Argument matchers

Mockito 提供了 “参数匹配器”(使用 ArgMatcher 类)的概念来捕获参数,并且追踪命名的参数如何传递。大多数情况下,普通参数和参数匹配器可以传递到 mock 方法中:

// 它们自身可以使用 普通参数
when(cat.eatFood("fish")).thenReturn(true);

// ... 包含集合
when(cat.walk(["roof","tree"])).thenReturn(2);

// ... 或匹配器
when(cat.eatFood(argThat(startsWith("dry")))).thenReturn(false);
when(cat.eatFood(any)).thenReturn(false);

// ... 或带匹配器的混合参数
when(cat.eatFood(argThat(startsWith("dry")), hungry: true)).thenReturn(true);
expect(cat.eatFood("fish"), isTrue);
expect(cat.walk(["roof","tree"]), equals(2));
expect(cat.eatFood("dry food"), isFalse);
expect(cat.eatFood("dry food", hungry: true), isTrue);

// 也可以验证参数匹配器的使用
verify(cat.eatFood("fish"));
verify(cat.walk(["roof","tree"]));
verify(cat.eatFood(argThat(contains("food"))));

// 可以验证 setter
cat.lives = 9;
verify(cat.lives=9);

如果一个参数不是 ArgMatcher, (如 any、 anyNamed、 argThat、 captureThat、等) 传递给 mock 方法的参数,那么 equals 匹配器用于参数匹配。 如果需要更严格的匹配,考虑下使用 argThat(identical(arg))

尽管这样,注意 null 不能用作 ArgMatcher 参数相邻的参数,或传递一个未包装的值作为命名参数。例如:

verify(cat.hunt("backyard", null)); // OK:没有参数匹配器
verify(cat.hunt(argThat(contains("yard")), null)); // BAD:相邻参数为 null
verify(cat.hunt(argThat(contains("yard")), argThat(isNull))); // OK:使用参数匹配器包装
verify(cat.eatFood("Milk", hungry: null)); // BAD:使用 null 作为命名参数
verify(cat.eatFood("Milk", hungry: argThat(isNull))); // BAD:使用 null 作为命名参数

命名参数

关于此语法,Mockito 现在有一个尴尬的麻烦:命名参数和参数匹配器需要比想象中更多的配置:必须在参数匹配器中声明参数的名称。这是因为我们无法依赖命名参数的位置,并且语言没有提供一个机制来回答 ”这个元素是在用作命名元素” 吗?

// GOOD: 参数匹配器包含它们的名称。
when(cat.eatFood(any, hungry: anyNamed('hungry'))).thenReturn(true);
when(cat.eatFood(any, hungry: argThat(isNotNull, named: 'hungry'))).thenReturn(false);
when(cat.eatFood(any, hungry: captureAnyNamed('hungry'))).thenReturn(false);
when(cat.eatFood(any, hungry: captureThat(isNotNull, named: 'hungry'))).thenReturn(true);

// BAD: 参数匹配器不包含它们的名称。
when(cat.eatFood(any, hungry: any)).thenReturn(true);
when(cat.eatFood(any, hungry: argThat(isNotNull))).thenReturn(false);
when(cat.eatFood(any, hungry: captureAny)).thenReturn(false);
when(cat.eatFood(any, hungry: captureThat(isNotNull))).thenReturn(true);

验证精确的调用次数 / 至少 x 次 / 永不

使用 verify 或者 verifyNever:

cat.sound();
cat.sound();

// 精确的调用次数
verify(cat.sound()).called(2);

// 或使用匹配器
verify(cat.sound()).called(greaterThan(1));

// 或永不调用
verifyNever(cat.eatFood(any));

按顺序验证

使用 verifyInOrder

cat.eatFood("Milk");
cat.sound();
cat.eatFood("Fish");
verifyInOrder([
  cat.eatFood("Milk"),
  cat.sound(),
  cat.eatFood("Fish")
]);

按顺序验证是弹性的 - 不需要一个接一个地验证交互,而是只按顺序验证感兴趣的交互。

确认交互在 mock 中从未发生

使用 verifyZeroInteractions

verifyZeroInteractions(cat);

发现冗余调用

使用 verifyNoMoreInteractions

cat.sound();
verify(cat.sound());
verifyNoMoreInteractions(cat);

为之后的断言捕获参数

使用 captureAny、 captureThat、 和 captureAnyNamed 参数匹配器:

// 简单捕获
cat.eatFood("Fish");
expect(verify(cat.eatFood(captureAny)).captured.single, "Fish");

// 捕获多个调用
cat.eatFood("Milk");
cat.eatFood("Fish");
expect(verify(cat.eatFood(captureAny)).captured, ["Milk", "Fish"]);

// 条件捕获
cat.eatFood("Milk");
cat.eatFood("Fish");
expect(verify(cat.eatFood(captureThat(startsWith("F")))).captured, ["Fish"]);

等待交互

使用 untilCalled

// 等待调用
cat.eatFood("Fish");
await untilCalled(cat.chew()); // 当 cat.chew() 被调用时完成

// 等待已经发生的调用
cat.eatFood("Fish");
await untilCalled(cat.eatFood(any)); // 当即完成

编写 fake

也可以通过继承Fake编写一个简单的假类来实现真实的类。Fake可以使子类满足真实类的实现,而无需覆写在测试中没有用到的方法。 Fake 类实现了抛出UnimplementedError 的默认行为(可以在假的类中覆写):

// 假的类
class FakeCat extends Fake implements Cat {
  @override
  bool eatFood(String food, {bool? hungry}) {
    print('Fake eat $food');
    return true;
  }
}

void main() {
  // 在运行时创建一个新的假 Cat(实例)。
  var cat = FakeCat();

  cat.eatFood("Milk"); // 打印 'Fake eat Milk'。
  cat.sleep(); // 抛出
}

重置 mock

使用 reset

// 清空集成的所有交互:
cat.eatFood("Fish");
clearInteractions(cat);
cat.eatFood("Fish");
verify(cat.eatFood("Fish")).called(1);

// 重置插桩和集成的交互:
when(cat.eatFood("Fish")).thenReturn(true);
cat.eatFood("Fish");
reset(cat);
when(cat.eatFood(any)).thenReturn(false);
expect(cat.eatFood("Fish"), false);

调试

使用 logInvocations 和 throwOnMissingStub

// 打印 mock 对象列表的任意 mock 方法的所有集成的交互:
logInvocations([catOne, catTwo]);

// 如果调用的 mock 方法没有一个匹配的插桩,每次都会抛出异常:
throwOnMissingStub(cat);

最佳实践

相对于使用 mock 测试,使用真实的对象测试是优先的 - 如果可以为测试构造一个真实的实例的话,就该这样去做。 如果在测试中没有调用 verify , 这是完全不需要 mock 的强烈信号,即使使用如插桩的 Mock 也没什么问题。 当无法使用真实的对象时,使用假类的可测试实现是第二选择 - 相对于在测试中的真实响应,它几乎可以有和真实的类一样的行为。最后,继承 Fake 的对象使用手动覆写的方法,优先于 继承 Mock 的对象作为插桩或 mock 使用。

继承 Mock 的类 决不 应该在构造方法中或其它地方使用 when 插桩它自身的响应。 在测试中插桩的响应应该在它使用的地方定义。 因为无论是对于整个接口,或者使用 继承 Fake 跳过接口的一些部分,响应的控制都是在使用 @override 方法的测试之外。

类似地,继承 Mock 的类 决不 就该有任何 @override 方法。 它们无法被测试插桩或者被 Mockito 追踪和验证。

同时定义了插桩响应的测试和定义了覆写的 mock 的混合会导致混乱。 如果有助于代码结构,可以对 继承 Mock 的类定义 静态的 工具(类或方法)。

常见问题

更多关于该包的信息,请阅读 FAQ