Flutter testWidgets with flutter_bloc – tests fail only when executed together

Issue

I’m having a problem with the attached widget tests in flutter. When I run the tests individually, each of them succeeds; however, when I run the entire main() method, the first three tests succeed but the last two fail with the following exception:

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

I understand that the exception means that the widget I’m expecting is not present – what I don’t understand is why the test succeeds when run individually but fails after being run after other tests. Is there some instance I need to be “resetting” after each test?

I’ve tried inserting “final SemanticsHandle handle = tester.ensureSemantics();” at the start of each tests and “handle.dispose();” at the end of each test but got the same results.

EDIT:
After some further investigating it seems like the problem may be with how I manage bloc instances using the flutter_bloc package. I have altered my tests to create a new testWidget instance for each test but am still encountering the same problem. Is there anything I may be missing that would cause a bloc instance to persist across testWidget objects?

My new test code looks like this:

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);
  });
}

For additional reference, I have also included the code for the VoucherRedemptionPage and VoucherRedemptionScreen below:

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,
                    ),
                  );
                }
              },
            ),
          ],
        )
      ],
    );
  }
}

Solution

Found the problem. I was using the singleton pattern when creating an instance of the bloc – this caused states to persist across different widget objects. Very unlikely that anyone will encounter the same problem that I did but below is the code that I changed to mitigate the problem

Old problematic code:

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

  //...
}

Updated working code:

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

  //...
}

Answered By – Marcus Bornman

Answer Checked By – Mildred Charles (FlutterFixes Admin)

Leave a Reply

Your email address will not be published. Required fields are marked *