带有 flutter_bloc 的 Flutter testWidgets - 测试仅在一起执行时失败

Flutter testWidgets with flutter_bloc - tests fail only when executed together

我在 flutter 中对附加的小部件测试有问题。当我 运行 单独测试时,每个测试都成功;但是,当我 运行 整个 main() 方法时,前三个测试成功但后两个测试失败,但出现以下异常:

Expected: exactly one matching node in the widget tree
  Actual: ?:<zero widgets with type "SuccessDialog" (ignoring offstage widgets)>

我知道异常意味着我期待的小部件不存在 - 我不明白的是为什么测试在单独 运行 时成功但在 运行 之后失败其他测试。每次测试后有没有我需要 "resetting" 的情况?

我试过在每个测试开始时插入 "final SemanticsHandle handle = tester.ensureSemantics();" 并在每个测试结束时插入 "handle.dispose();" 但得到的结果相同。

编辑: 经过进一步调查,问题似乎出在我如何使用 flutter_bloc 包管理 bloc 实例。我已经改变了我的测试,为每个测试创建一个新的 testWidget 实例,但我仍然遇到同样的问题。是否有任何我可能遗漏的东西会导致 bloc 实例在 testWidget 对象中持续存在?

我的新测试代码如下所示:

main() {
  MvnoMockClient.init();

  testWidgets(
      'Voucher Redemption: Tapping redeem when no values were entered yields 2 field errors',
      (WidgetTester tester) async {
    Widget testWidget = MediaQuery(
      data: MediaQueryData(),
      child: MaterialApp(
        home: VoucherRedemptionPage(onSuccess: () {}, onFail: () {}),
      ),
    );
    await tester.pumpWidget(testWidget);

    await tester.tap(find.byType(PrimaryCardButton));
    await tester.pump();

    expect(find.text("Field is required"), findsNWidgets(2));
  });

  testWidgets(
      'Voucher Redemption: Tapping redeem when only voucher number was entered yields one field error',
      (WidgetTester tester) async {
    Widget testWidget = MediaQuery(
      data: MediaQueryData(),
      child: MaterialApp(
        home: VoucherRedemptionPage(onSuccess: () {}, onFail: () {}),
      ),
    );
    await tester.pumpWidget(testWidget);

    await tester.enterText(find.byType(PlainTextField), "0000000000");
    await tester.tap(find.byType(PrimaryCardButton));
    await tester.pump();

    expect(find.text("Field is required"), findsOneWidget);
  });

  testWidgets(
      'Voucher Redemption: Tapping redeem when only mobile number was entered yields one field error',
      (WidgetTester tester) async {
    Widget testWidget = MediaQuery(
      data: MediaQueryData(),
      child: MaterialApp(
        home: VoucherRedemptionPage(onSuccess: () {}, onFail: () {}),
      ),
    );
    await tester.pumpWidget(testWidget);

    await tester.enterText(find.byType(MsisdnField), "0815029249");
    await tester.tap(find.byType(PrimaryCardButton));
    await tester.pump();

    expect(find.text("Field is required"), findsOneWidget);
  });

  testWidgets(
      'Voucher Redemption: A successful server response yields a success dialog',
      (WidgetTester tester) async {
    Widget testWidget = MediaQuery(
      data: MediaQueryData(),
      child: MaterialApp(
        home: VoucherRedemptionPage(onSuccess: () {}, onFail: () {}),
      ),
    );
    await tester.pumpWidget(testWidget);

    await tester.enterText(find.byType(PlainTextField), "0000000000");
    await tester.enterText(find.byType(MsisdnField), "0815029249");
    await tester.tap(find.text("REDEEM"));
    await tester.pump();

    expect(find.byType(SuccessDialog), findsOneWidget);
  });

  testWidgets(
      'Voucher Redemption: An unsuccessful server response yields an error dialog',
      (WidgetTester tester) async {
    Widget testWidget = MediaQuery(
      data: MediaQueryData(),
      child: MaterialApp(
        home: VoucherRedemptionPage(onSuccess: () {}, onFail: () {}),
      ),
    );
    await tester.pumpWidget(testWidget);

    gToken = "invalid";
    await tester.enterText(find.byType(PlainTextField), "0000000000");
    await tester.enterText(find.byType(MsisdnField), "0815029249");
    await tester.tap(find.byType(PrimaryCardButton));
    await tester.pump();
    gToken = "validToken";

    expect(find.byType(ErrorDialog), findsOneWidget);
  });
}

作为额外参考,我还在下面包含了 VoucherRedemptionPage 和 VoucherRedemptionScreen 的代码:

class VoucherRedemptionPage extends StatelessWidget {
  final onSuccess;
  final onFail;

  const VoucherRedemptionPage({Key key, @required this.onSuccess, @required this.onFail})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    var _voucherRedemptionBloc = new VoucherRedemptionBloc();
    return Container(
      decoration: BoxDecoration(
        image: DecorationImage(
            image: AssetImage("assets/" + gFlavor + "/primary_background.png"),
            fit: BoxFit.cover),
      ),
      child: new Scaffold(
        backgroundColor: Colors.transparent,
        appBar: new AppBar(
          title: new Text(gDictionary.find("Redeem Voucher")),
        ),
        body: new VoucherRedemptionScreen(
          voucherRedemptionBloc: _voucherRedemptionBloc,
          onSuccess: this.onSuccess,
          onFail: this.onFail,
        ),
      ),
    );
  }
}


class VoucherRedemptionScreen extends StatefulWidget {
  const VoucherRedemptionScreen({
    Key key,
    @required VoucherRedemptionBloc voucherRedemptionBloc,
    @required this.onSuccess,
    @required this.onFail,
  })  : _voucherRedemptionBloc = voucherRedemptionBloc,
        super(key: key);

  final VoucherRedemptionBloc _voucherRedemptionBloc;
  final onSuccess;
  final onFail;

  @override
  VoucherRedemptionScreenState createState() {
    return new VoucherRedemptionScreenState(
        _voucherRedemptionBloc, onSuccess, onFail);
  }
}

class VoucherRedemptionScreenState extends State<VoucherRedemptionScreen> {
  final VoucherRedemptionBloc _voucherRedemptionBloc;
  final onSuccess;
  final onFail;
  TextEditingController _msisdnController = TextEditingController();
  TextEditingController _voucherPinController = TextEditingController();
  GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  VoucherRedemptionScreenState(
      this._voucherRedemptionBloc, this.onSuccess, this.onFail);

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<VoucherRedemptionEvent, VoucherRedemptionState>(
      bloc: _voucherRedemptionBloc,
      builder: (
        BuildContext context,
        VoucherRedemptionState currentState,
      ) {
        if (currentState is VoucherRedemptionInitial) {
          _voucherPinController.text = currentState.scannedNumber;
          return _buildFormCard();
        }

        if (currentState is VoucherRedemptionLoading) {
          return Center(
            child: CircularProgressIndicator(),
          );
        }

        if (currentState is VoucherRedemptionSuccess) {
          return SuccessDialog(
            title: gDictionary.find("Voucher Redeemed Successfully"),
            description: currentState.successMessage,
            closeText: gDictionary.find("OK"),
            closeAction: () {
              this.onSuccess();
              _voucherRedemptionBloc.dispatch(ResetVoucherRedemptionState());
            },
          );
        }

        if (currentState is VoucherRedemptionError) {
          return ErrorDialog(
            errorCode: currentState.errorCode,
            errorMessage: currentState.errorMessage,
            closeText: gDictionary.find("OK"),
            closeAction: () {
              this.onFail();
              _voucherRedemptionBloc.dispatch(ResetVoucherRedemptionState());
            },
          );
        }
      },
    );
  }

  Widget _buildFormCard() {
    return Container(
      decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.only(
              topLeft: Radius.circular(8), topRight: Radius.circular(8))),
      padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
      width: double.infinity,
      height: double.infinity,
      child: _buildCardContent(),
    );
  }

  Widget _buildCardContent() {
    return SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            gDictionary.find("Transaction Amount"),
            style: TextStyle(
                fontSize: 14,
                color: Theme.of(context).primaryColorDark,
                fontWeight: FontWeight.bold),
          ),
          Container(height: 16),
          Form(
            key: _formKey,
            child: _buildFormContent(),
          ),
        ],
      ),
    );
  }

  Column _buildFormContent() {
    return Column(
      children: <Widget>[
        PlainTextField(
          controller: _voucherPinController,
          label: gDictionary.find("Voucher Number"),
          required: true,
        ),
        Container(height: 16),
        MsisdnField(
          controller: _msisdnController,
          label: gDictionary.find("Mobile Number"),
          required: true,
        ),
        Divider(),
        Row(
          mainAxisAlignment: MainAxisAlignment.end,
          children: <Widget>[
            SecondaryCardButton(
              text: gDictionary.find("SCAN VOUCHER"),
              onPressed: () {
                _voucherRedemptionBloc.dispatch(
                  ScanBarcode(),
                );
              },
            ),
            Container(
              width: 8.0,
            ),
            PrimaryCardButton(
              text: gDictionary.find("REDEEM"),
              onPressed: () {
                if (_formKey.currentState.validate()) {
                  _voucherRedemptionBloc.dispatch(
                    RedeemVoucher(
                      _voucherPinController.text,
                      _msisdnController.text,
                    ),
                  );
                }
              },
            ),
          ],
        )
      ],
    );
  }
}

这可能是因为您的测试改变了一些全局变量但没有重置它们的值。

确保安全的一种方法是始终使用 setUptearDown 而不是直接在 main 范围内改变变量:

int global = 0;

void main() {
  final initialGlobalValue = global;
  setUp(() {
    global = 42;
  });
  tearDown(() {
    global = initialGlobalValue;
  });


  test('do something with "global"', () {
    expect(++global, 43);
  });

  test('do something with "global"', () {
    // would fail without setUp/tearDown
    expect(++global, 43);
  });
}

同样,如果测试需要修改变量,请使用 addTearDown 而不是稍后在测试中手动重置值。

不要:

int global = 0;
test("don't", () {
  global = 43;
  expect(global, 43);
  global = 0;
})

DO:

int global = 0;
test('do', () {
  global = 43;
  addTearDown(() => global = 0);

  expect(global, 43);     
});

这确保即使测试失败,该值也将始终被重置——以便其他测试正常运行。

找到问题了。我在创建 bloc 的实例时使用了单例模式——这导致状态在不同的小部件对象中持续存在。很少有人会遇到与我相同的问题,但下面是我为缓解问题而更改的代码

旧有问题的代码:

class VoucherRedemptionBloc
    extends Bloc<VoucherRedemptionEvent, VoucherRedemptionState> {
  static final VoucherRedemptionBloc _voucherRedemptionBlocSingleton =
      new VoucherRedemptionBloc._internal();
  factory VoucherRedemptionBloc() {
    return _voucherRedemptionBlocSingleton;
  }
  VoucherRedemptionBloc._internal();

  //...
}

更新的工作代码:

class VoucherRedemptionBloc
    extends Bloc<VoucherRedemptionEvent, VoucherRedemptionState> {
  VoucherRedemptionBloc();

  //...
}

在我的例子中,我没有设置 skipOffstage: false,做这样的事情对我有用:

expect(find.text('text', skipOffstage: false), findsNWidgets(2));