To make Flutter Bloc logic testable, extract business logic into service methods or classes, isolating it from state management. This guide provides a step-by-step approach with examples.
1. Understand the Goal
Why extract logic? Blocs often mix business logic (e.g., API calls, data processing) with state management, making them hard to test. Extracting logic into services:
- Improves testability by isolating dependencies.
- Enhances maintainability and reusability.
- Keeps Blocs focused on coordinating events and states.
Key principle: Blocs handle what to do (state transitions), while services handle how to do it (business logic).
2. Identify Logic to Extract
Analyze your Bloc for:
- API calls or external data operations (e.g., HTTP requests, database queries).
- Complex computations or transformations (e.g., filtering, mapping data).
- Domain-specific rules (e.g., validation, business logic).
- Side effects (e.g., writing to storage, logging).
Move these to service classes, leaving the Bloc to emit states.
3. Create Service Classes
What are services? Plain Dart classes encapsulating specific functionality.
How to structure them?
- Define interfaces (abstract classes) for services to enable dependency injection and mocking.
- Implement concrete service classes for the logic.
- Inject services into the Bloc via its constructor.
Example: Refactoring a TodoBloc
Before Extraction (Mixed Logic in Bloc)
class TodoBloc extends Bloc<TodoEvent, TodoState> {
TodoBloc() : super(TodoInitial()) {
on<FetchTodos>((event, emit) async {
emit(TodoLoading());
try {
final response = await http.get(Uri.parse('https://api.example.com/todos'));
final todos = jsonDecode(response.body) as List;
final filteredTodos = todos.where((todo) => todo['completed'] == false).toList();
emit(TodoLoaded(filteredTodos));
} catch (e) {
emit(TodoError('Failed to fetch todos'));
}
});
}
}
This Bloc mixes API calls and filtering logic with state management.
Step 1: Define a Service Interface
abstract class TodoService {
Future<List<dynamic>> fetchTodos();
List<dynamic> filterIncompleteTodos(List<dynamic> todos);
}
Step 2: Implement the Service
class TodoApiService implements TodoService {
final http.Client client;
TodoApiService(this.client);
@override
Future<List<dynamic>> fetchTodos() async {
final response = await client.get(Uri.parse('https://api.example.com/todos'));
if (response.statusCode == 200) {
return jsonDecode(response.body) as List;
} else {
throw Exception('Failed to fetch todos');
}
}
@override
List<dynamic> filterIncompleteTodos(List<dynamic> todos) {
return todos.where((todo) => todo['completed'] == false).toList();
}
}
3: Inject the Service into the Bloc
class TodoBloc extends Bloc<TodoEvent, TodoState> {
final TodoService todoService;
TodoBloc(this.todoService) : super(TodoInitial()) {
on<FetchTodos>((event, emit) async {
emit(TodoLoading());
try {
final todos = await todoService.fetchTodos();
final filteredTodos = todoService.filterIncompleteTodos(todos);
emit(TodoLoaded(filteredTodos));
} catch (e) {
emit(TodoError('Failed to fetch todos'));
}
});
}
}
The Bloc now delegates logic to the service, focusing on state emission.
4. Set Up Dependency Injection
Use a dependency injection package like get_it or provider, or pass services manually.
Example with get_it
final getIt = GetIt.instance;
void setup() {
getIt.registerSingleton<TodoService>(TodoApiService(http.Client()));
}
void main() {
setup();
runApp(MyApp());
}
In your app:
BlocProvider(
create: (context) => TodoBloc(getIt<TodoService>()),
child: MyHomePage(),
)
5. Write Tests for Services
Services are plain Dart classes, making them easy to test with mockito or mocktail.
Example Test for TodoApiService
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
class MockHttpClient extends Mock implements http.Client {}
void main() {
late TodoApiService todoService;
late MockHttpClient mockHttpClient;
setUp(() {
mockHttpClient = MockHttpClient();
todoService = TodoApiService(mockHttpClient);
});
test('fetchTodos returns list of todos on success', () async {
when(mockHttpClient.get(any)).thenAnswer(
(_) async => http.Response('[{"id": 1, "completed": false}]', 200),
);
final todos = await todoService.fetchTodos();
expect(todos, isA<List>());
expect(todos.length, 1);
expect(todos[0]['id'], 1);
});
test('fetchTodos throws exception on failure', () async {
when(mockHttpClient.get(any)).thenAnswer(
(_) async => http.Response('Error', 500),
);
expect(() => todoService.fetchTodos(), throwsException);
});
test('filterIncompleteTodos filters out completed todos', () {
final todos = [
{'id': 1, 'completed': false},
{'id': 2, 'completed': true},
];
final filtered = todoService.filterIncompleteTodos(todos);
expect(filtered.length, 1);
expect(filtered[0]['id'], 1);
});
}
Testing the Bloc
Mock the service to test the Bloc’s state transitions.
class MockTodoService extends Mock implements TodoService {}
void main() {
late TodoBloc todoBloc;
late MockTodoService mockTodoService;
setUp(() {
mockTodoService = MockTodoService();
todoBloc = TodoBloc(mockTodoService);
});
test('emits [TodoLoading, TodoLoaded] when fetchTodos succeeds', () async {
when(mockTodoService.fetchTodos())
.thenAnswer((_) async => [{'id': 1, 'completed': false}]);
when(mockTodoService.filterIncompleteTodos(any))
.thenReturn([{'id': 1, 'completed': false}]);
todoBloc.add(FetchTodos());
await expectLater(
todoBloc.stream,
emitsInOrder([
isA<TodoLoading>(),
isA<TodoLoaded>(),
]),
);
});
}
6. Progressively Extract More Logic
Create additional services for other logic, e.g.:
- AuthService for authentication.
- DataRepository for multiple data sources.
- ValidatorService for input validation.
Example (Validation Service)
abstract class ValidatorService {
bool isValidTodoTitle(String title);
}
class TodoValidatorService implements ValidatorService {
@override
bool isValidTodoTitle(String title) {
return title.isNotEmpty && title.length <= 50;
}
}
Update the Bloc:
class TodoBloc extends Bloc<TodoEvent, TodoState> {
final TodoService todoService;
final ValidatorService validatorService;
TodoBloc(this.todoService, this.validatorService) : super(TodoInitial()) {
on<AddTodo>((event, emit) async {
if (validatorService.isValidTodoTitle(event.title)) {
// Proceed with adding todo
} else {
emit(TodoError('Invalid title'));
}
});
}
}
7. Best Practices for Testable Services
- Single Responsibility: Each service handles one type of logic.
- Immutable Data: Use immutable data or pure functions where possible.
- Error Handling: Centralize error handling in services.
- Mocking: Use interfaces for mockable dependencies.
- Unit Testing: Test each service method in isolation.
8. Example Folder Structure
lib/
├── blocs/
│ └── todo_bloc.dart
├── services/
│ ├── todo_service.dart
│ ├── todo_api_service.dart
│ └── validator_service.dart
├── models/
│ └── todo.dart
├── screens/
│ └── home_page.dart
└── main.dart
9. Common Pitfalls to Avoid
- Overcomplicating Services: Avoid turning services into god objects.
- Leaking UI Logic: Keep services UI-agnostic.
- Not Mocking Dependencies: Always mock external dependencies.
- Ignoring Error States: Ensure services propagate errors clearly.
10. Testing the Entire Flow
Use integration tests for the app’s behavior and widget tests for UI updates.
Example Integration Test
testWidgets('Todo screen displays todos', (tester) async {
await tester.pumpWidget(
BlocProvider(
create: (_) => TodoBloc(TodoApiService(http.Client())),
child: MaterialApp(home: TodoScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('Todo 1'), findsOneWidget);
});
Summary
- Extract business logic into service classes with interfaces.
- Inject services into Blocs using dependency injection.
- Write unit tests for services using mocks.
- Keep Blocs focused on state management.
- Refactor progressively by isolating logic.
