Using the BLoC-Pattern

Following the introduction of the BLoC pattern in the first post we will now see how we can use and test it.

Using Cubit/Bloc

To observe state changes in our Cubit/Bloc we can override the onChange method.
If we want to react to state changes in our UI, we first need to provide the Cubit/Bloc to the UI using a BlocProvider. This allows us to access the Cubit and its state in the subtree of our Widget tree.

@override
Widget build(BuildContext context) {
  return BlocProvider(
    create: (context) => UserCubit(),
    child: Center(
      child: Padding(
        padding: const EdgeInsets.only(right: 5),
        child: child,
      ),
    ),
  );
}

After that we can use Widgets like BlocBuilder and BlocListener inside our view Widgets.

In our Paperchase app, for instance, we have a Widget that displays the number of points the user has gained so far.

When we call increasePoints, the UserCubit emits a new state with the updated number of points. The builder function of our BlocBuilder responds to these state changes and renders the UI with the updated number of points.

class AppBarPointsWidget extends StatelessWidget {
  const AppBarPointsWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocBuilder(builder: (context, state) {
      return Text(
        state.points.toString(),
        style: TextStyle(fontSize: 25),
      );
    });
  }
}

If you want to show a modal when the state changes, you can use BlocListener.

BlocListener<BlocA, BlocAState>(
  listener: (context, state) {
    if(state == true){
    // do something like show dialog
    },
  child: Container(),
)

 

Testing

Now that we have discussed what Blocs and Cubits are and how to use them, we will learn to test our logic.
For testing, we will use the Flutter packages mocktail and bloc_test.


Cubit tests

Let’s start by testing our business logic inside the Cubits. To do this, we will use the bloc_test package which provides us a blocTest. In this blocTest we can define:

seed: the input state
build: the Cubit instance under test
act: the callback, which will be invoked with the Cubit under test
expect: the expected state change

void main() {
  late AnimationState animationState;
  late AnimationCubit animationCubit;

  setUp(() {
    animationCubit = AnimationCubit();
    animationState = animationCubit.state;
  });

  group('When setCompassAnimation to true', () {
    blocTest(
      'Then isCompassAnimation should be true',
      seed: () => state.copyWith(isCompassAnimated: false),
      build: () => animationCubit,
      act: (cubit) => animationCubit.setCompassAnimation(true),
      expect: () => [state.copyWith(isCompassAnimated: true)],
    );
  });
});


“Dumb”-Widget tests

In this context, a “dumb”-Widget is a Widget that doesn’t use a Cubit or its state.
To tests these Widgets, we can use simple Widget tests.

In our example, we want to test a simple AppBarItemWidget with a child Widget as parameter.

import 'package:flutter/material.dart';

class AppBarItemWidget extends StatelessWidget {
  final Widget child;
  const AppBarItemWidget({
    required this.child,
  });

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.only(right: 5),
        child: child,
      ),
    );
  }
}

In our test, we wrap the Widget with a MaterialApp. This is necessary because our Widget has components that come from the Material part of Flutter UI components. That is why it needs a MaterialApp as a parent.

After rendering the UI with pumpWidget, we expect to find the text we provided to the Widget as a child.

void main() {
  testWidgets('Then should show Text', (WidgetTester tester) async {
    await tester.pumpWidget(
        MaterialApp(home: AppBarItemWidget(child: Text('hello world'))));

    expect(find.text('hello world'), findsOneWidget);
  });
}


Widget tests

Lastly, we want to test a Widget that uses our Cubit methods and provided state.
Our example is the AppBarPointsWidget, which can trigger the increasePoints() method of our Cubit and renders a text which displays the current points state.

class AppBarPointsWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final userCubit = context.read();

    return BlocBuilder(builder: (context, state) {
      return Column(children: [
        Text(
          state.points.toString(),
          style: TextStyle(fontSize: 25),
        ),
        TextButton(
          onPressed: userCubit.increasePoints,
          child: Text('click me'),
        )
      ]);
    });
  }
}

To test this Widget, we first need to mock our Cubit because we want to unit-test the Widget, which means we do not want to use the real Cubit. If we used the real Cubit, we would test the Widget as well as the Cubit, which would be more of an integration test instead of a unit test.

After creating the cubitMock, we define the state our mock returns with the when() method.
Now, we can simply render our Widget as we saw before and test to see if the rendered output is as expected.

testWidgets('Should show widget with mocked state',
      (WidgetTester tester) async {
  UserCubit cubitMock = MockUserCubit();

  when(() => cubitMock.state)
      .thenReturn(UserState.initial().copyWith(points: 101));

  await tester.pumpWidget(
    MaterialApp(
      home: BlocProvider.value(
        value: cubitMock,
        child: AppBarPointsWidget(),
      ),
    ),
  );

  expect(find.text('101'), findsOneWidget);
});

Additionally, we can test whether the Cubit method is called when we tap the button.
To do this, we write another test that taps a button and verifies that the correct method is called.

testWidgets('Should call IncreasePoints', (WidgetTester tester) async {
  UserCubit cubitMock = MockUserCubit();

  when(() => cubitMock.state)
      .thenReturn(UserState.initial().copyWith(points: 101));

  await tester.pumpWidget(
    MaterialApp(
      home: BlocProvider.value(
        value: cubitMock,
        child: AppBarPointsWidget(),
      ),
    ),
  );

  await tester.tap(find.text('click me'));

  verify(() => cubitMock.increasePoints()).called(1);
});

 

Conclusion

As you can see, the BLoC pattern approach helps us create a maintainable architecture where we can easily unit test our business logic as well as our views.
A testable architecture is important, but the BLoC pattern is not the only way to achieve this goal. Depending on the technology and the project, there are a lot of other patterns and approaches you can use.

Regarding state management, in addition to BLoC, Flutter provides many other packages and approaches you can use in your project, like Riverpod or flutter_redux.
Redux, for example, is often used in web applications.

Patterns like MVVM or MVC are also widespread when it comes to software development. It is Important to use an architecture that is maintainable and testable.

Michael Sandner
Letzte Artikel von Michael Sandner (Alle anzeigen)