Hey there!
I was wondering if it's possible to test the code inside a BlocListener's listener, and if so, how?
Already checked out https://github.com/felangel/bloc/issues/335 , but that sadly only seems to work for bloc-to-bloc communication :( The only thing that I can think of right now is that we don't mock the bloc, but the repository inside that bloc's mapEventToState, so that the bloc functions as usual. Is this the best way to go about it?
Thanks in advance for your reply!
Yours,
Bas
Hi @Tregan ๐
Thanks for opening an issue!
Can you please provide the code that you wish to test and the test code which is not working? It would be much easier for me to help if I see the specific example that you want to test.
In general, you can check out the BlocListener Tests for an example.
Let me know if that helps ๐
Hey @felangel ,
Sorry for the late reaction, had some things going on.
We're using bloc-to-bloc communication, where our login page bloc listens to state changes from our authorization data bloc (which in turn communicates with the requires repositories). We want to test whether our login form bloclistener shows a snackbar when we expect it to. Code that we have:
LoginFormState (Form inside LoginPage)
class LoginFormState extends State<LoginForm> {
@override
Widget build(BuildContext context) {
BlocListener<LoginEvent, LoginState>(
key: Key('loginBlocListener'),
bloc: _loginBloc,
listener: (BuildContext context, LoginState state) {
String error = '';
if (state is LoginFailure) {
//TODO error = ...
}
if (state is FormValidated) {
if (!state.isEmailValid || !state.isPasswordValid) {
error = !state.isEmailValid ? 'Please enter a valid email address' : 'Please fill in your password';
}
}
if (error.isNotEmpty) {
Scaffold.of(context).showSnackBar(
SnackBar(
key: Key('snackBar'),
content: Text(error),
),
);
}
},
child: Container()
)
}
}
LoginBloc the LoginForm listens to
class LoginBloc extends Bloc<LoginEvent, LoginState> {
AuthData.AuthenticationDataBloc _authDataBloc;
StreamSubscription _authDataBlocSubscription;
LoginBloc({@required AuthData.AuthenticationDataBloc authenticationDataBloc}) {
_authDataBloc = authenticationDataBloc;
_authDataBlocSubscription = authenticationDataBloc.state.listen(_onAuthenticationDataBlocStateChange);
}
@override
Stream<LoginState> mapEventToState(LoginEvent event) async* {
if (event is FormSubmitted) {
yield Validating();
bool isEmailValid = _validateEmail(event.email);
bool isPasswordValid = _validatePassword(event.password);
yield FormValidated(isEmailValid: isEmailValid, isPasswordValid: isPasswordValid);
if (isEmailValid && isPasswordValid) {
_authDataBloc.dispatch(
AuthData.LogIn(
email: event.email,
password: event.password,
),
);
yield LoggingIn();
}
}
if (event is LoginSuccess) {
yield LoginSuccessful();
}
if (event is LoginFailure) {
yield LoginError(loginError: event.loginError);
}
}
void _onAuthenticationDataBlocStateChange(AuthData.AuthenticationDataState state) {
if (state is AuthData.SessionLoaded) {
_authDataBloc.dispatch(AuthData.FetchUser());
}
if (state is AuthData.UserLoaded) {
this.dispatch(LoginSuccess());
}
if (state is AuthData.AuthenticationDataErrorState) {
this.dispatch(LoginFailure(loginError: state));
}
}
}
AuthenticationDataBloc that the LoginBloc listens to
class AuthenticationDataBloc extends Bloc<AuthenticationDataEvent, AuthenticationDataState> {
@override
Stream<AuthenticationDataState> mapEventToState(AuthenticationDataEvent event) async* {
if (event is LogIn) {
yield SessionLoading();
try {
final Session session = await authRepository.fetchSessionId(
email: event.email,
password: event.password,
deviceId: '1', //TODO: use deviceUtils to retrieve device info
deviceName: 'device', //TODO: use deviceUtils to retrieve device info
);
requestHandler.sessionId = session.sessionId;
yield SessionLoaded(session: session);
} on RequestException catch (e) {
AuthenticationDataErrorState errorState = AuthenticationDataErrorState.fromRequestException(e);
yield errorState;
}
}
if (event is FetchUser) {
yield UserLoading();
final User user = await authRepository.fetchUser();
yield UserLoaded(user: user);
}
yield initialState;
}
}
Test code:
class MockAuthenticationRepository extends Mock implements AuthenticationRepository {}
class MockRequestHandler extends Mock implements RequestHandler {}
void main() {
group('login error handling', () {
MockAuthenticationRepository authRepository;
MaterialApp app;
LoginBloc loginBloc;
setUp(() {
authRepository = MockAuthenticationRepository();
loginBloc = LoginBloc(
authenticationDataBloc: AuthenticationDataBloc(
authRepository: authRepository,
requestHandler: MockRequestHandler(),
),
);
app = MaterialApp(
home: Scaffold(
body: LoginForm(
key: formKey,
loginBloc: loginBloc,
),
),
);
});
group('form validation', () {
testWidgets('success', (tester) async {
List<LoginState> expectedStates = [
Idle(),
Validating(),
FormValidated(isEmailValid: true, isPasswordValid: true),
LoggingIn(),
LoginSuccessful(),
];
await tester.pumpWidget(app);
Finder finder = find.byKey(Key('snackBar'));
expect(finder, findsNothing);
when(
authRepository.fetchSessionId(
email: '[email protected]',
password: '123',
deviceId: '1',
deviceName: 'device',
),
).thenAnswer((_) async => Session(sessionId: '1'));
when(
authRepository.fetchUser(),
).thenAnswer(
(_) async => User(
id: '1',
email: '[email protected]',
firstName: 'firstName',
lastName: 'lastName',
),
);
await tester.enterText(find.byKey(Key('emailField')), '[email protected]');
await tester.pumpAndSettle();
await tester.enterText(find.byKey(Key('passwordField')), '123');
await tester.pumpAndSettle();
await tester.tap(find.byKey(Key('loginButton')));
await tester.pumpAndSettle();
expectLater(loginBloc.state, emitsInOrder(expectedStates)).then((_) {
expect(finder, findsNothing);
});
});
});
});
}
We get the following error:
LoginForm login error handling form validation success:
โโโก EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The following TimeoutException was thrown running a test (but after the test had completed):
TimeoutException after 0:00:09.000000: The test exceeded the timeout. It may have hung.
Consider using "tester.binding.addTime" to increase the timeout before expensive operations.When the exception was thrown, this was the stack:
2 AutomatedTestWidgetsFlutterBinding._checkTimeout (package:flutter_test/src/binding.dart:911:25)
16 _Timer._runTimers (dart:isolate-patch/timer_impl.dart:382:19)
17 _Timer._handleMessage (dart:isolate-patch/timer_impl.dart:416:5)
18 _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:171:12)
22 AutomatedTestWidgetsFlutterBinding.runTest (package:flutter_test/src/binding.dart:964:27)
23 testWidgets.(package:flutter_test/src/widget_tester.dart:106:22)
24 Declarer.test.. . (package:test_api/src/backend/declarer.dart:168:27)
27 Declarer.test.. . (package:test_api/src/backend/declarer.dart:0:0)
28 Invoker.waitForOutstandingCallbacks.(package:test_api/src/backend/invoker.dart:250:15)
34 Invoker._onRun.. . (package:test_api/src/backend/invoker.dart:399:21)
(elided 24 frames from package dart:async, package dart:async-patch, and package stack_trace)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ERROR: Bad state: Future already completed
dart:async _Completer.completeError
package:flutter_test/src/binding.dart 911:25 AutomatedTestWidgetsFlutterBinding._checkTimeout
===== asynchronous gap ===========================
dart:async new Timer.periodic
package:flutter_test/src/binding.dart 964:27 AutomatedTestWidgetsFlutterBinding.runTest
package:flutter_test/src/widget_tester.dart 106:22 testWidgets.
@Tregan no worries and thanks for providing more details! Is there any way you can provide a link to a sample repo that illustrates the problem you're having? It'd be much easier for me to help if I can run and debug the tests locally.
@felangel Certainly! I've created one here, with as little code as possible to show the issue
https://github.com/Tregan/bloc_sample
You'll encounter the issue by running the login_form_test
Thanks for taking the time!
@felangel we actually found a solution (using your bloclistener test, slowly building up to what we wanted to test, using your examples), but we have one question that you may or may not have the answer to:
one of our tests (invalid password, which checks if a snackbar is shown after an error from the bloc), doesn't work when we setup the MaterialApp in setUp(), but it works fine when we create it in the test itself.
So this doesn't work (Note that all keys are defined in main itself):
group('login error handling', () {
AuthenticationRepository authRepository;
MaterialApp app;
setUp(() {
authRepository = MockAuthenticationRepository();
final bloc = LoginBloc(authDataBloc: AuthenticationDataBloc(authRepository: authRepository, requestHandler: MockRequestHandler()));
app = MaterialApp(
home: Scaffold(
body: LoginForm(
loginBloc: bloc,
),
),
);
});
testWidgets('invalid password', (tester) async {
await tester.pumpWidget(app);
Finder finder = find.byKey(snackBar);
expect(finder, findsNothing);
await tester.enterText(find.byKey(emailField), '[email protected]');
await tester.pumpAndSettle();
await tester.enterText(find.byKey(passwordField), '');
await tester.pumpAndSettle();
await tester.tap(find.byKey(loginButton));
await tester.pumpAndSettle();
expect(finder, findsOneWidget);
});
});
This works fine:
group('login error handling', () {
testWidgets('invalid password', (tester) async {
AuthenticationRepository authRepository = MockAuthenticationRepository();
final bloc = LoginBloc(authDataBloc: AuthenticationDataBloc(authRepository: authRepository, requestHandler: MockRequestHandler()));
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: LoginForm(
loginBloc: bloc,
),
),
));
Finder finder = find.byKey(snackBar);
expect(finder, findsNothing);
await tester.enterText(find.byKey(emailField), '[email protected]');
await tester.pumpAndSettle();
await tester.enterText(find.byKey(passwordField), '');
await tester.pumpAndSettle();
await tester.tap(find.byKey(loginButton));
await tester.pumpAndSettle();
expect(finder, findsOneWidget);
});
});
Do you maybe have any idea why this is? We thought it would be cleaner code if we could just setup everything once instead of in every test, and setUp is called for every test, right?
@Tregan glad you figured it out! Regarding your second question, that should work just fine. Are you able to share a link where I can reproduce and debug the issue? Thanks!
@felangel Funny thing, I started updating the sample repo so I could you show you the issue, but it seems to work fine now for some reason... Like, literally the same code that failed on friday, now just works. No idea why, but I guess it's all good for now!
@Tregan haha weird...if it comes up again feel free to let me know ๐
Most helpful comment
@felangel Funny thing, I started updating the sample repo so I could you show you the issue, but it seems to work fine now for some reason... Like, literally the same code that failed on friday, now just works. No idea why, but I guess it's all good for now!