You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter单元测试:Mockito多存根引发MissingStubError问题

Mockito MissingStubError 解决与方法调用验证

问题场景

使用Mockito 5.3.2测试BookingService的createBooking方法时,触发MissingStubError,提示调用createCashbookItem(Instance of 'CashbookItem')无匹配存根。该方法逻辑为:当传入的Receipt的paymentType等于kPaymentTypeBar时,会调用CashbookItemRepository的createCashbookItem方法。

原因分析

Mockito默认使用严格对象实例相等性匹配存根。测试代码中手动创建了cashbookItem并基于该实例设置存根,但待测试方法createBooking内部是通过CashbookItem.fromReceipt(receipt)生成全新的CashbookItem实例——两个实例内存地址不同,因此Mockito无法匹配到对应的存根。

解决方案

方案1:使用参数匹配器(推荐)

通过Mockito的参数匹配器,忽略实例本身,匹配对象的核心属性。

步骤1:定义自定义匹配器

创建一个匹配器函数,对比CashbookItem的关键属性:

Matcher matchesCashbookItem(CashbookItem expected) {
  return predicate((actual) {
    final item = actual as CashbookItem;
    // 根据业务需求,对比需要验证的核心属性
    return item.amount == expected.amount &&
        item.paymentType == expected.paymentType &&
        item.category == expected.category &&
        item.documentNumber == expected.documentNumber;
  });
}

步骤2:更新存根逻辑

用自定义匹配器替换原有的实例匹配:

when(mockCashbookItemRepository.createCashbookItem(argThat(matchesCashbookItem(cashbookItem))))
    .thenAnswer((_) async => const Right(unit));

如果不需要精确匹配,也可以用any()快速解决(适合简单场景):

when(mockCashbookItemRepository.createCashbookItem(any()))
    .thenAnswer((_) async => const Right(unit));

方案2:为CashbookItem实现相等性判断

重写CashbookItem类的==运算符和hashCode方法,让内容相同的实例被认为相等:

class CashbookItem {
  // 类属性定义
  final double amount;
  final String paymentType;
  final String category;
  final String documentNumber;

  CashbookItem({
    required this.amount,
    required this.paymentType,
    required this.category,
    required this.documentNumber,
    // 其他属性
  });

  // 从Receipt创建的方法
  factory CashbookItem.fromReceipt(Receipt receipt) {
    return CashbookItem(
      amount: receipt.amount,
      paymentType: receipt.paymentType,
      category: receipt.category,
      documentNumber: receipt.documentNumber,
      // 其他属性映射
    );
  }

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is CashbookItem &&
          runtimeType == other.runtimeType &&
          amount == other.amount &&
          paymentType == other.paymentType &&
          category == other.category &&
          documentNumber == other.documentNumber;

  @override
  int get hashCode => Object.hash(amount, paymentType, category, documentNumber);
}

完整测试代码(方案1示例)

@GenerateMocks([CashbookRepository, ReceiptRepository, CashbookItemRepository])
void main() {
  late BookingService bookingService;
  late MockCashbookRepository mockCashbookRepository;
  late MockCashbookItemRepository mockCashbookItemRepository;
  late MockReceiptRepository mockReceiptRepository;

  setUp(() {
    mockCashbookRepository = MockCashbookRepository();
    mockCashbookItemRepository = MockCashbookItemRepository();
    mockReceiptRepository = MockReceiptRepository();
    bookingService = BookingServiceImpl(
      receiptRepository: mockReceiptRepository,
      cashbookItemRepository: mockCashbookItemRepository,
      cashbookRepository: mockCashbookRepository,
    );
  });

  // 自定义匹配器
  Matcher matchesCashbookItem(CashbookItem expected) {
    return predicate((actual) {
      final item = actual as CashbookItem;
      return item.amount == expected.amount &&
          item.paymentType == expected.paymentType &&
          item.category == expected.category &&
          item.documentNumber == expected.documentNumber;
    });
  }

  group("bookings", () {
    var t_OldReceipt = Receipt.empty();

    test("create booking should call createCashbookItem when paymentType is bar", () async {
      // Arrange
      t_OldReceipt = t_OldReceipt.copyWith(
          amount: 10,
          totalAmount: 10,
          taking: true,
          category: "test",
          taxRate: 0,
          paymentType: kPaymentTypeBar,
          documentNumber: "1",
          taxAmount: 0,
          receiptDate: DateTime.now(),
          year: "2023",
          yearMonth: "20232",
          yearMonthDay: "2023210",
          contact: Contact.empty());

      final cashbookItem = CashbookItem.fromReceipt(t_OldReceipt);

      when(mockReceiptRepository.createReceipt(t_OldReceipt))
          .thenAnswer((_) async => const Right(unit));
      when(mockCashbookItemRepository.createCashbookItem(argThat(matchesCashbookItem(cashbookItem))))
          .thenAnswer((_) async => const Right(unit));

      // Act
      final result = await bookingService.createBooking(t_OldReceipt);

      // Assert
      expect(result, const Right(unit));
      // 验证createCashbookItem是否被调用一次
      verify(mockCashbookItemRepository.createCashbookItem(argThat(matchesCashbookItem(cashbookItem)))).called(1);
      // 验证receiptRepository的createReceipt被调用
      verify(mockReceiptRepository.createReceipt(t_OldReceipt)).called(1);
    });
  });
}

验证方法调用

使用Mockito的verify()方法确认createCashbookItem是否被正确调用:

  • 精确验证:结合自定义匹配器,确保传入的参数符合预期
  • 次数验证:通过.called(n)指定调用次数(n为整数,比如1表示调用一次)

内容的提问来源于stack exchange,提问作者Matthias Mayer

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.01 11:35:22