Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Flutter testWidgets with flutter_bloc - tests fail only when executed together

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,
                    ),
                  );
                }
              },
            ),
          ],
        )
      ],
    );
  }
}
like image 346
Marcus Bornman Avatar asked Apr 29 '19 05:04

Marcus Bornman


3 Answers

In my case I wasn't setting skipOffstage: false, doing something like this worked for me:

expect(find.text('text', skipOffstage: false), findsNWidgets(2));
like image 28
ERP Avatar answered Nov 15 '22 18:11

ERP


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

  //...
}
like image 65
Marcus Bornman Avatar answered Nov 15 '22 20:11

Marcus Bornman


That likely happens because your tests mutate some global variable but do not reset their value.

One way to make it safe is to always use setUp and tearDown instead of mutating variables directly the main scope:

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

Similarly, if a test needs to modify a variable, use addTearDown instead of manually resetting the value later in the test.

DON'T:

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

This ensures that the value will always be reset even if the tests fails – so that other test functions normally.

like image 30
Rémi Rousselet Avatar answered Nov 15 '22 20:11

Rémi Rousselet