A Practical Guide to the BLoC Pattern in Flutter with Testing

Shyam Jith

--

Introduction

Managing state in a Flutter app can get tricky as your app grows more complex. That’s where the BLoC (Business Logic Component) pattern comes in handy. It helps you organize your code by separating your business logic from the UI, making your app more maintainable, scalable, and easier to test.

In this guide, we’ll explore:

  • What the BLoC pattern is and why you might want to use it
  • How to implement BLoC in Flutter with a simple example
  • How to write tests for your BLoC to ensure everything works as expected

What is the BLoC Pattern?

BLoC is an acronym for Business Logic Component, a type of design pattern, isolating business logic from your UI components. What it does is to let your streams of data define your user interface in the form of reactive programming. It encourages you to break down your app into smaller pieces with clear responsibilities, thereby making your code modular, testable, and reusable.

Key Concepts of BLoC :

  • Streams: A stream of asynchronous data. The UI listens for updates on the stream and renders itself.
  • Sink: This is where you input the data/events to be processed.
  • Events: Any sort of interaction by the user or another trigger that invokes business logic.
  • State: The outcome of business logic operations that the UI will render.

Why Use BLoC?

  • Clear separation of concerns: Your UI doesn’t handle logic directly, which makes the app easier to maintain.
  • Testability: With the logic separated from the UI, you can write focused tests that ensure your business logic works.
  • Consistency: Using a consistent design for the state and events across an application.

Implementing the BLoC Pattern with Flutter

Step 1: Adding Dependencies

To start using BLoC, you need to add the required packages to your pubspec.yaml file:

 dependencies:
flutter_bloc: ^8.1.6 # BLoC library
equatable: ^2.0.5 # For comparing objects

Step 2: Creating the BLoC

Let’s take a simple counter app where users can increment or decrement a value. In fact, we have two main components: CounterEvent — the event that happens due to user actions, and CounterBloc — where business logic takes place.

CounterEvent

Events are user actions. For our counter app, we will have two actions: increment and decrement.

import 'package:equatable/equatable.dart';

abstract class CounterEvent extends Equatable {
@override
List<Object> get props => [];
}

class IncrementEvent extends CounterEvent {}

class DecrementEvent extends CounterEvent {}

class DecrementEvent extends CounterEvent {}

CounterState

Then, we declare the state the UI will respond to. Here, it is simply an integer value denoting the counter’s value.

class CounterState extends Equatable {
final int counterValue;

CounterState({required this.counterValue});

@override
List<Object?> get props => [counterValue];

CounterBloc

The business logic for handling events and updating the state:

import 'package:flutter_bloc/flutter_bloc.dart';

class CounterBloc extends Bloc<CounterEvent, CounterState>
CounterBloc() : super(CounterState(counterValue: 0)) {
on<IncrementEvent> { event, emit ->
emit(CounterState(counterValue: state.counterValue + 1));
}
on<DecrementEvent> { event, emit ->
emit(CounterState(counterValue: state.counterValue - 1));
}
}
}

Connecting BLoC to the UI

Now that the BLoC is ready, let’s hook it up to the UI so that the app can respond to events like button taps and display the updated state.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterBloc(),
child: Scaffold(
appBar: AppBar(title: Text('Counter App')),
body: CounterView(),
),
);
}
}

class CounterView extends StatelessWidget {\r
@override\r
Widget build(BuildContext context) {\r
final counterBloc = BlocProvider.of<CounterBloc>(context);\r
\r
return Center(\r
child: Column(\r
mainAxisAlignment: MainAxisAlignment.center,\r
children: [\r
BlocBuilder<CounterBloc, CounterState>
builder: (context, state) {
return Text('Counter Value: ${state.counterValue}');
},
),
Row
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
onPressed: () => counterBloc.add(IncrementEvent()),
icon: Icon(Icons.add),
),
IconButton(
onPressed: () => counterBloc.add(DecrementEvent()),
icon: Icon(Icons.remove),
),
],
),
],
),
);
}
}

Testing the BLoC Pattern

Since BLoC is designed to keep business logic separate from the UI, testing becomes easier. You can write unit tests for your BLoC to ensure it produces the correct states when events are triggered.

Step 1: Unit Testing the BLoC

We will use the bloc_test package for testing.

Add it to your dev_dependencies:

dev_dependencies:
bloc_test: ^9.1.7
flutter_test:
sdk: flutter

Step 2: Writing Tests

We will write unit tests to make sure CounterBloc responds to an increment and decrement event correctly.

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/counter_bloc.dart';

void main() {
group('CounterBloc', () {
blocTest<CounterBloc, CounterState>(
'emits [1] when IncrementEvent is added',
build: () => CounterBloc(),
act: (bloc) => bloc.add(IncrementEvent()),
expect(() => [CounterState(counterValue: 1)]),
);

blocTest<CounterBloc, CounterState>(
'emits [-1] when DecrementEvent is added',
build: () => CounterBloc(),
act: (bloc) => bloc.add(DecrementEvent()),
expect: () => [CounterState(counterValue: -1)],
);
});
}

Step 3: Widget Testing

Testing the UI Enters user interactions and checks if the UI changes accordingly. You check if, after clicking the increment button, the text value showing the current counter is updated.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/counter_app.dart';

void main() {
testWidgets('Counter increments and decrements', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: CounterApp()));

expect(find.text('Counter Value: 0'), findsOneWidget);

// Simulate pressing the increment button
await tester.tap(find.byIcon(Icons.add));
await tester.pump();

expect(find.text('Counter Value: 1'), findsOneWidget);

// Simulate pressing the decrement button
await tester.tap(find.byIcon(Icons.remove));
await tester.pump();

expect(find.text('Counter Value: 0'), findsOneWidget);
});
}

Conclusion

BLoC cleans up your Flutter app codebase so as to effectively separate business logic from the UI. An application develops modularity, turns to be more maintainable and easier to test this way. It utilizes streams and sinks to ensure that your app is responsive and scalable when it grows. Also, testing BLoC is much easier according to examples of unit and widget testing introduced in this guide.

With BLoC in a Flutter project, you will end up with a clean and efficient application with far more robust test coverage than you otherwise would have.

Happy coding!

--

--

No responses yet