Flutter testWidgets with flutter_bloc — тесты терпят неудачу только при совместном выполнении

У меня проблема с прикрепленными тестами виджетов во флаттере. Когда я запускаю тесты по отдельности, каждый из них проходит успешно; однако, когда я запускаю весь метод main(), первые три теста завершаются успешно, а последние два терпят неудачу со следующим исключением:

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

Я понимаю, что исключение означает, что виджет, который я ожидаю, отсутствует - я не понимаю, почему тест завершается успешно при индивидуальном запуске, но терпит неудачу после запуска после других тестов. Есть ли какой-то случай, когда мне нужно «сбрасывать» после каждого теста?

Я попытался вставить "final SemanticsHandle handle = tester.ensureSemantics();" в начале каждого теста и "handle.dispose();" в конце каждого теста, но получили одинаковые результаты.

Обновлено: После некоторого дальнейшего изучения кажется, что проблема может заключаться в том, как я управляю экземплярами блока с помощью пакета флаттер_блок. Я изменил свои тесты, чтобы создать новый экземпляр testWidget для каждого теста, но все еще сталкиваюсь с той же проблемой. Есть ли что-то, что я могу упустить, что может привести к тому, что экземпляр блока будет сохраняться в объектах 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,
                    ),
                  );
                }
              },
            ),
          ],
        )
      ],
    );
  }
}
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
6
0
4 329
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Вероятно, это происходит из-за того, что ваши тесты изменяют какую-то глобальную переменную, но не сбрасывают ее значение.

Один из способов сделать это безопасным — всегда использовать setUp и tearDown вместо того, чтобы изменять переменные непосредственно в области 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;
})

ДЕЛАТЬ:

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

  expect(global, 43);     
});

Это гарантирует, что значение всегда будет сбрасываться, даже если тесты не пройдены, так что другие тесты работают нормально.

Спасибо за помощь. Однако после того, как вы попробовали ваше решение и провели дополнительное расследование, кажется, что проблема может быть связана с пакетом flutter_bloc, который я использую.

Marcus Bornman 29.04.2019 14:38
Ответ принят как подходящий

Нашел проблему. Я использовал шаблон singleton при создании экземпляра блока - это приводило к тому, что состояния сохранялись в разных объектах виджета. Очень маловероятно, что кто-то столкнется с той же проблемой, что и я, но ниже приведен код, который я изменил, чтобы смягчить проблему.

Старый проблемный код:

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

Другие вопросы по теме