У меня проблема с прикрепленными тестами виджетов во флаттере. Когда я запускаю тесты по отдельности, каждый из них проходит успешно; однако, когда я запускаю весь метод 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,
),
);
}
},
),
],
)
],
);
}
}





Вероятно, это происходит из-за того, что ваши тесты изменяют какую-то глобальную переменную, но не сбрасывают ее значение.
Один из способов сделать это безопасным — всегда использовать 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);
});
Это гарантирует, что значение всегда будет сбрасываться, даже если тесты не пройдены, так что другие тесты работают нормально.
Нашел проблему. Я использовал шаблон 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));
Спасибо за помощь. Однако после того, как вы попробовали ваше решение и провели дополнительное расследование, кажется, что проблема может быть связана с пакетом flutter_bloc, который я использую.