Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bloc_test): testBlocFakeAsync - fire all asynchronous events without actually needing to wait for real time to elapse #3796

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
268 changes: 268 additions & 0 deletions packages/bloc_test/lib/src/bloc_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:diff_match_patch/diff_match_patch.dart';
import 'package:fake_async/fake_async.dart';
import 'package:meta/meta.dart';
import 'package:test/test.dart' as test;

Expand Down Expand Up @@ -170,6 +171,273 @@ void blocTest<B extends BlocBase<State>, State>(
);
}

/// Creates a new `bloc`-specific test case with the given [description].
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we could also add some sort of example using fakeAsync.elapse which is kind of the main purpose of this new method, wdyt?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might wanna update the README.md docs as well and any other sort of documentation bloc_test might have

/// [fakeAsyncBlocTest] will handle asserting that the `bloc` emits the
/// [expect]ed states (in order) after [act] is executed.
///
/// The main difference of [fakeAsyncBlocTest] from [blocTest] is that [setUp],
/// [act] and [tearDown] 'Functions' have parameter of type [FakeAsync] which
/// provide explicitly control Dart's notion of the "current time". When the
/// time is advanced, FakeAsync fires all asynchronous events that are scheduled
/// for that time period without actually needing the test to wait for real time
/// to elapse.
///
/// [fakeAsyncBlocTest] also handles ensuring that no additional states are
/// emitted by closing the `bloc` stream before evaluating the [expect]ation.
///
/// [setUp] is optional and should be used to set up any dependencies prior to
/// initializing the `bloc` under test and 'fakeAsync' to fire asynchronous
/// events that are scheduled for that time period.
/// [setUp] should be used to set up state necessary for a particular test case.
/// For common set up code, prefer to use `setUp` from `package:test/test.dart`.
///
/// [build] should construct and return the `bloc` under test.
///
/// [seed] is an optional `Function` that returns a state
/// which will be used to seed the `bloc` before [act] is called.
///
/// [act] is an optional callback which will be invoked with the `bloc` under
/// test and should be used to interact with the `bloc` and 'fakeAsync' to
/// fire asynchronous events that are scheduled for that time period.
///
/// [skip] is an optional `int` which can be used to skip any number of states.
/// [skip] defaults to 0.
///
/// [wait] is an optional `Duration` which can be used to wait for
/// async operations within the `bloc` under test such as `debounceTime`.
///
/// [expect] is an optional `Function` that returns a `Matcher` which the `bloc`
/// under test is expected to emit after [act] is executed.
///
/// [verify] is an optional callback which is invoked after [expect]
/// and can be used for additional verification/assertions.
/// [verify] is called with the `bloc` returned by [build].
///
/// [errors] is an optional `Function` that returns a `Matcher` which the `bloc`
/// under test is expected to throw after [act] is executed.
///
/// [tearDown] is optional and can be used to execute any code after the
/// test has run
/// [tearDown] should be used to clean up after a particular test case.
/// For common tear down code, prefer to use `tearDown` from `package:test/test.dart`.
///
/// [tags] is optional and if it is passed, it declares user-defined tags
/// that are applied to the test. These tags can be used to select or
/// skip the test on the command line, or to do bulk test configuration.
///
/// ```dart
/// fakeAsyncBlocTest(
/// 'CounterBloc emits [1] when increment is added',
/// build: () => CounterBloc(),
/// act: (bloc, fakeAsync) => bloc.add(CounterEvent.increment),
/// expect: () => [1],
/// );
/// ```
///
/// [fakeAsyncBlocTest] can optionally be used with a seeded state.
///
/// ```dart
/// fakeAsyncBlocTest(
/// 'CounterBloc emits [10] when seeded with 9',
/// build: () => CounterBloc(),
/// seed: () => 9,
/// act: (bloc, fakeAsync) => bloc.add(CounterEvent.increment),
/// expect: () => [10],
/// );
/// ```
///
/// [fakeAsyncBlocTest] can also be used to [skip] any number of emitted states
/// before asserting against the expected states.
/// [skip] defaults to 0.
///
/// ```dart
/// fakeAsyncBlocTest(
/// 'CounterBloc emits [2] when increment is added twice',
/// build: () => CounterBloc(),
/// act: (bloc, fakeAsync) {
/// bloc
/// ..add(CounterEvent.increment)
/// ..add(CounterEvent.increment);
/// },
/// skip: 1,
/// expect: () => [2],
/// );
/// ```
///
/// [fakeAsyncBlocTest] can also be used to wait for async operations
/// by optionally providing a `Duration` to [wait].
///
/// ```dart
/// fakeAsyncBlocTest(
/// 'CounterBloc emits [1] when increment is added',
/// build: () => CounterBloc(),
/// act: (bloc, fakeAsync) => bloc.add(CounterEvent.increment),
/// wait: const Duration(milliseconds: 300),
/// expect: () => [1],
/// );
/// ```
///
/// [fakeAsyncBlocTest] can also be used to [verify] internal bloc
/// functionality.
///
/// ```dart
/// fakeAsyncBlocTest(
/// 'CounterBloc emits [1] when increment is added',
/// build: () => CounterBloc(),
/// act: (bloc, fakeAsync) => bloc.add(CounterEvent.increment),
/// expect: () => [1],
/// verify: (_) {
/// verify(() => repository.someMethod(any())).called(1);
/// }
/// );
/// ```
///
/// **Note:** when using [fakeAsyncBlocTest] with state classes which don't
/// override `==` and `hashCode` you can provide an `Iterable` of matchers
/// instead of explicit state instances.
///
/// ```dart
/// fakeAsyncBlocTest(
/// 'emits [StateB] when EventB is added',
/// build: () => MyBloc(),
/// act: (bloc, fakeAsync) => bloc.add(EventB()),
/// expect: () => [isA<StateB>()],
/// );
/// ```
///
/// If [tags] is passed, it declares user-defined tags that are applied to the
/// test. These tags can be used to select or skip the test on the command line,
/// or to do bulk test configuration. All tags should be declared in the
/// [package configuration file][configuring tags]. The parameter can be an
/// [Iterable] of tag names, or a [String] representing a single tag.
///
/// [configuring tags]: https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md#configuring-tags
@isTest
void fakeAsyncBlocTest<B extends BlocBase<State>, State>(
String description, {
void Function(FakeAsync async)? setUp,
required B Function() build,
State Function()? seed,
Function(B bloc, FakeAsync async)? act,
Duration? wait,
int skip = 0,
dynamic Function()? expect,
Function(B bloc)? verify,
dynamic Function()? errors,
void Function(FakeAsync async)? tearDown,
dynamic tags,
}) {
test.test(
description,
() {
testBlocFakeAsync<B, State>(
setUp: setUp,
build: build,
seed: seed,
act: act,
wait: wait,
skip: skip,
expect: expect,
verify: verify,
errors: errors,
tearDown: tearDown,
);
},
tags: tags,
);
}

/// Internal [testBlocFakeAsync] runner which is only visible for testing.
/// This should never be used directly -- please use [fakeAsyncBlocTest]
/// instead.
@visibleForTesting
void testBlocFakeAsync<B extends BlocBase<State>, State>({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method duplicates a lot of code that can be found on testBloc. What about refactoring both to share as much code as possible so maintenance is simpler?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tenhobi @mugbug there's a few reasons to not unify the code:

  1. Breaking change
  2. fakeAsync require sync execution flow, which lead us to critical differences as:

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I get it, but my suggestion was to share internal code, not external. So we'd still have the current blocTest and testBlocFakeAsync methods, but the internal code of them will be somehow shared. In this case we won't have breaking changes since the public API for blocTest will remain the same. Not sure about the 2nd point but I suppose we can choose which code to share, so we should be good as well. Does it make sense?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got your point, @mugbug The duplicated code has been extracted to a private functions.

cc: @tenhobi

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Now I suppose we just need to wait for an approval on the code solution and then work on some examples and documentation updates. Good job!

void Function(FakeAsync async)? setUp,
required B Function() build,
State Function()? seed,
Function(B bloc, FakeAsync fakeAsync)? act,
Duration? wait,
int skip = 0,
dynamic Function()? expect,
Function(B bloc)? verify,
dynamic Function()? errors,
void Function(FakeAsync async)? tearDown,
}) {
var errorThrown = false;
var shallowEquality = false;
final unhandledErrors = <Object>[];
final localBlocObserver =
// ignore: deprecated_member_use
BlocOverrides.current?.blocObserver ?? Bloc.observer;
final testObserver = _TestBlocObserver(
localBlocObserver,
unhandledErrors.add,
);
Bloc.observer = testObserver;

fakeAsync((fakeAsync) => runZonedGuarded(
() {
setUp?.call(fakeAsync);
final states = <State>[];
final bloc = build();
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
if (seed != null) bloc.emit(seed());
final subscription = bloc.stream.skip(skip).listen(states.add);

try {
act?.call(bloc, fakeAsync);
fakeAsync.elapse(Duration.zero);
} catch (error) {
if (errors == null) rethrow;
unhandledErrors.add(error);
}
if (wait != null) fakeAsync.elapse(wait);

fakeAsync.elapse(Duration.zero);
unawaited(bloc.close());

if (expect != null && !errorThrown) {
final dynamic expected = expect();
shallowEquality = '$states' == '$expected';
try {
test.expect(states, test.wrapMatcher(expected));
} on test.TestFailure catch (e) {
if (shallowEquality || expected is! List<State>) rethrow;
final diff = _diff(expected: expected, actual: states);
final message = '${e.message}\n$diff';
// ignore: only_throw_errors
throw test.TestFailure(message);
}
}

unawaited(subscription.cancel());
verify?.call(bloc);
tearDown?.call(fakeAsync);

fakeAsync.flushMicrotasks();
},
(Object error, _) {
if (shallowEquality && error is test.TestFailure) {
errorThrown = true;
// ignore: only_throw_errors
throw test.TestFailure(
'''${error.message}
WARNING: Please ensure state instances extend Equatable, override == and hashCode, or implement Comparable.
Alternatively, consider using Matchers in the expect of the blocTest rather than concrete state instances.\n''',
);
}
if (errors == null || !unhandledErrors.contains(error)) {
errorThrown = true;
// ignore: only_throw_errors
throw error;
}
},
));
if (errors != null) {
test.expect(unhandledErrors, test.wrapMatcher(errors()));
}
}

/// Internal [blocTest] runner which is only visible for testing.
/// This should never be used directly -- please use [blocTest] instead.
@visibleForTesting
Expand Down
1 change: 1 addition & 0 deletions packages/bloc_test/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ environment:
dependencies:
bloc: ^8.1.1
diff_match_patch: ^0.4.1
fake_async: ^1.3.1
meta: ^1.3.0
mocktail: ">=0.2.0 <0.4.0"
test: ^1.16.0
Expand Down