In the previous blog post we talked about the importance of testing in app development, especially for Flutter developers. We went into details about the three main types of tests in Flutter: unit, widget, and integration tests, and delved into some the best practices for effective testing. One of the best practices is Test Driven Development (TDD), a methodology that deserves its own deep dive. That’s why in this post, I’m dedicating the spot light to TDD.
Test Driven Development is a software development practice that focuses on writing tests for a feature/behavior before implementing it . While it may seem counterintuitive at first, this iterative approach is proven to promote clarity of requirements, helps catch bugs early in the development process, and leads to cleaner, more maintainable code.
In this post, I’ll explain the principles behind TDD and guide you through how to apply them in a Flutter project. By the end, you’ll see how TDD can transform the way you approach development and elevate your Flutter apps to the next level.
The TDD Cycle: Red, Green, Refactor
At the core of TDD lies a cycle known as Red, Green, Refactor. This cycle provides a structured approach to writing clean, reliable code while ensuring that all functionality is properly tested from the outset.
Red: Write a Failing Test
In this first step, we write a test for the functionality want to implement. At this point, the feature doesn’t exist yet, so the test will fail. This “red” phase ensures that you’re starting with a clear definition of what your code is supposed to accomplish. It also gives you an initial failing state to verify your progress.
Green: Write Minimal Code to Pass the Test
Next, you write just enough code to make the test pass. The goal here isn’t to create the perfect solution, but to implement the simplest possible code that meets the test’s requirements.
Refactor: Improve the Code While Keeping Tests Passing
Once the test is passing, it’s time to refactor. This phase is where you clean up the code, optimize it, and make it more maintainable without changing its functionality. Since the tests are already in place, they act as a safety net to ensure that your refactoring doesn’t break anything.
Applying the Cycle in Flutter
In Flutter development, the Red, Green, Refactor cycle can be applied across unit, widget, and integration tests. For example:
- Red: Write a unit test to validate a method in a Cubit or Provider.
- Green: Implement the method minimally, so the test passes.
- Refactor: Optimize the Cubit logic, handle edge cases, or improve state management without breaking the test.
By iterating through this cycle, you not only build confidence in your code but also develop a disciplined workflow that emphasizes quality and maintainability.
Let’s use a project to have a deeper understanding of how to apply this in Flutter. For this blog post we will revisit and recreate the project from the clean architecture post but this time we will be practicing Test Driven Development across unit test.
Set Up Flutter Project for TDD
Before diving into Test Driven Development (TDD), we should have the right tools and project setup in place.
If you are familiar with Flutter you may know that the testing framework is built-in, which makes it straightforward to write and run tests. Let’s walk through the steps to get your project ready for TDD.
Folder Structure
Open Android Studio ( or your preferred IDE… I don’t judge) and create a new Flutter project. We’ll call it my_tdd_app. If the test folder isn’t automatically created you can add it yourself. We should have the following folder structure with the test folder at the root of the project.
my_tdd_app/
├── lib/
├── test/ <-- Create this folder
└── pubspec.yaml
Create the following folders in the lib folder and make sure the test folder mirrors it:
my_project/
├── lib/
│ ├── main.dart
│ ├── core/
│ │ └── errors/
│ │ │ └── failures.dart
│ │ └── services/
│ │ │ └── service_locator.dart
│ ├── features/
│ │ └── user_profile/
│ │ ├── presentation/
│ │ ├── domain/
│ │ └── data/
├── test/
│ ├── features/
│ │ └── user_profile/
│ │ ├── presentation/
│ │ ├── domain/
│ │ └── data/
│ └── widget_test.dart
└── pubspec.yaml
To speed things up a bit here’s the code for the core/errors/failures.dart …
import 'package:equatable/equatable.dart';
class Failure extends Equatable {
final String message;
const Failure(this.message);
@override
List<Object?> get props => [message];
}
and core/services/service_locator.dart
import 'package:my_tdd_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:my_tdd_app/features/user_profile/data/repositories/user_repository_impl.dart';
import 'package:my_tdd_app/features/user_profile/domain/repositories/user_repository.dart';
import 'package:my_tdd_app/features/user_profile/domain/use_cases/get_user_profile.dart';
import 'package:my_tdd_app/features/user_profile/presentation/user_profile_cubit/user_profile_cubit.dart';
import 'package:get_it/get_it.dart';
import 'package:http/http.dart';
final serviceLocator = GetIt.instance;
Future<void> setUpDependencies() async {
// App Logic
serviceLocator.registerFactory(() => UserProfileCubit(getUserProfile: serviceLocator()));
// Use cases
serviceLocator.registerLazySingleton(() => GetUserProfile(serviceLocator()));
// Repositories
serviceLocator.registerLazySingleton<UserRepository>(() => UserRepositoryImpl(serviceLocator()));
// Data sources
serviceLocator.registerLazySingleton<UserRemoteDataSource>(() => UserRemoteDataSourceImpl(serviceLocator()));
// External dependencies
serviceLocator.registerLazySingleton(() => Client.new);
}
Add Dependencies
Make sure the necessary packages are in the pubspec.yalm file
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.0.0 # For state management
get_it: ^7.2.0 # Dependency injection
http: ^1.2.2 # For API calls
equatable: ^2.0.5 # For value comparison
dartz: ^0.10.1 # Functional programming utilities (for Either)
...
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0 # For mocking classes and dependencies
bloc_test: ^9.1.0 # If you’re using BLoC for state management
Make sure you run flutter pub get
Applying TDD: Step-by-Step Example
As stated earlier, we’ll be revisiting the User Profile Feature from our Clean Architecture blog post. We’re building a function that retrieves a user’s profile by their ID. The feature will be built using Test-Driven Development, illustrating how TDD fits within a clean architecture setup.
TDD for Domain Layer
In domain folder create the file …domain/entities/user.dart
import 'package:equatable/equatable.dart';
class User extends Equatable {
const User({
required this.id,
required this.name,
});
final int id;
final String name;
@override
List<Object?> get props => [id, name];
}
the file …domain/repositories/user_repository.dart for the repository interface
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:dartz/dartz.dart';
abstract class UserRepository {
Future<Either<Failure, User>> getUserById(String id);
}
Repository
Step 1: Write the Test First (Red Phase)
The first step is to write a failing test that defines the expected behavior. In this case, we’ll test a GetUserProfile use case that retrieves a user entity by id.
in the test folder create the …domain/use_cases/get_user_profile_test.dart file and add the following code
import 'package:dartz/dartz.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/repositories/user_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late UserRepository repository;
late GetUserProfile getPostsUseCase;
setUp(() {
repository = MockUserRepository();
getPostsUseCase = GetUserProfile(repository);
});
const testResponse = User(id: 0, name: '_empty.name');
const params = '_empty.userId';
const testFailure = Failure('message');
group('GetUserProfile', () {
test(
'given GetUserProfile '
'when instantiated '
'then call [UserRepository.getUserById] '
'and return [User]',
() async {
// Arrange
when(() => repository.getUserById(params)).thenAnswer((_) async => const Right(testResponse));
// Act
final result = await getPostsUseCase(params);
// Assert
expect(result, equals(const Right<Failure, User>(testResponse)));
verify(() => repository.getUserById(params)).called(1);
verifyNoMoreInteractions(repository);
},
);
test(
'given GetUserProfile '
'when instantiated '
'and [UserRepository.getUserById] call unsuccessful'
'then return [Failure]',
() async {
// Arrange
when(() => repository.getUserById(params)).thenAnswer((_) async => const Left(testFailure));
// Act
final result = await getPostsUseCase(params);
// Assert
expect(result, equals(const Left<Failure, User>(testFailure)));
verify(() => repository.getUserById(params)).called(1);
verifyNoMoreInteractions(repository);
},
);
});
}
At this point, the test should fail because the GetUserProfile use case has not been implemented yet
Step 2: Write the Code to Make the Test Pass (Green Phase)
Next, we write the simplest implementation of the GetUserProfile use case that will satisfy the test cases.
Create the file …domain/use_cases/get_user_profile.dart
import 'package:dartz/dartz.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/repositories/user_repository.dart';
class GetUserProfile {
final UserRepository repository;
GetUserProfile(this.repository);
Future<Either<Failure, User>> call(String userId) {
return repository.getUserById(userId);
}
}
Then make sure the file is imported in the …domain/use_cases/get_user_profile_test.dart
import 'package:dartz/dartz.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/repositories/user_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_tdd_app/features/user_profile/domain/use_cases/get_user_profile.dart'; // New Import
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late UserRepository repository;
late GetUserProfile getPostsUseCase;
setUp(() {
repository = MockUserRepository();
getPostsUseCase = GetUserProfile(repository);
});
const testResponse = User(id: 0, name: '_empty.name');
const params = '_empty.userId';
const testFailure = Failure('message');
group('GetUserProfile', () {
...
});
}
Run the tests again, and they should now pass. At this stage, we’ve implemented the minimum code needed to make the tests pass.
Step 3: Refactor the Code (Refactor Phase)
With passing tests, you can safely refactor your code. In this case, the implementation is already straightforward, but you might want to add:
- Input validation in the call() method (e.g., ensuring userId is not empty)
- Additional comments or logging for better maintainability
TDD for Data Layer
Models
Models in the data layer are implementations of entities in the domain layer. So before we create UserModel let’s write the test.
First create a fixtures folder in your test directory. Inside it, save a file named user.json with the following content
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "[email protected]",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
}
The user.json file simulates a real API response, making your test more representative of real-world scenarios
create the fixture_reader.dart file in the fixtures folder and add a helper function to read JSON fixtures from the folder
import 'dart:io';
String fixture(String fileName) => File('test/fixtures/$fileName').readAsStringSync();
The fixture helper function reads the JSON file and returns its content as a String
Now we can proceed with the first step of TDD.
Step 1: Red Phase
create the …/data/models/user_model_test.dart file in the test folder
import 'package:flutter_test/flutter_test.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import '../../../../fixtures/fixture_reader.dart';
void main() {
final testJson = fixture('user.json');
final testUserModel = UserModel.fromJson(testJson);
final testMap = testUserModel.toJson();
test(
'given [UserModel], '
'when instantiated '
'then instance should be a subclass of [User] entity',
() {
// Arrange
// Act
// Assert
expect(testUserModel, isA<User>());
},
);
group('fromJson - ', () {
test(
'given [UserModel], '
'when fromJson is called, '
'then return [UserModel] with correct data ',
() {
// Arrange
// Act
final result = UserModel.fromJson(testJson);
// Assert
expect(result, isA<UserModel>());
expect(result, equals(testUserModel));
},
);
});
group('toJson - ', () {
test(
'given [UserModel], '
'when toJson is called, '
'then return a json String with correct data ',
() {
// Arrange
// Act
final result = testUserModel.toJson();
// Assert
expect(result, equals(testJson));
},
);
});
group('fromMap - ', () {
test(
'given [UserModel], '
'when fromMap is called, '
'then return [UserModel] with correct data ',
() {
// Arrange
// Act
final result = UserModel.fromMap(testMap);
// Assert
expect(result, isA<UserModel>());
expect(result, equals(testUserModel));
},
);
});
group('copyWith - ', () {
const testName = 'John Doe';
test(
'given [UserModel], '
'when fromMap is called, '
'then return [UserModel] with correct data ',
() {
// Arrange
// Act
final result = testUserModel.copyWith(name: testName);
// Assert
expect(result.name, equals(testName));
},
);
});
}
This will fail because UserModel has not been implemented yet
Step 2: Green Phase
Now we write the simplest implementation of UserModel to satisfy the tests.
import 'dart:convert';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
class UserModel extends User {
const UserModel({
required super.id,
required super.name,
});
factory UserModel.fromJson(String source) => UserModel.fromMap(
jsonDecode(source) as Map<String, dynamic>,
);
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
};
}
UserModel.fromMap(Map<String, dynamic> json)
: this(
id: int.tryParse(json['id'].toString()) ?? 0,
name: json['name'] == null ? '' : json['name'] as String,
);
UserModel copyWith({
int? id,
String? name,
}) {
return UserModel(
id: id ?? this.id,
name: name ?? this.name,
);
}
}
Make sure the user_model.dart is imported into the test file
import 'package:flutter_test/flutter_test.dart';
import 'package:my_tdd_app/features/user_profile/data/models/user_model.dart'; // New import
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import '../../../../fixtures/fixture_reader.dart';
void main() {
final testJson = fixture('user.json');
final testUserModel = UserModel.fromJson(testJson);
final testMap = testUserModel.toJson();
...
}
The tests should now pass.
Step 3: Refactor Phase
For the sake of brevity we aren’t going to refactor any of tests in this case. But if you want to you could write more tests for another method you want to add to UserModel.
Repositories Implementation
The repository dependents on the remote data source. So we’ll need to create the remote data source interface first. in the lib folder create the file …/data/data_sources/user_remote_data_source.dart
import 'package:my_tdd_app/features/user_profile/data/models/user_model.dart';
abstract class UserRemoteDataSource {
Future<UserModel> fetchUserById(String id);
}
Step 1: Red Phase
create the …/data/repositories/user_repository_impl_test.dart file in the test folder
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:my_tdd_app/features/user_profile/data/models/user_model.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/repositories/user_repository.dart';
class MockUserRemoteDataSource extends Mock implements UserRemoteDataSource {}
void main() {
late UserRemoteDataSource remoteDataSource;
late UserRepositoryImpl repositoryImpl;
setUp(() {
remoteDataSource = MockUserRemoteDataSource();
repositoryImpl = UserRepositoryImpl(remoteDataSource);
});
test(
'given UserRepositoryImpl '
'when instantiated '
'then instance should be a subclass of [AuthRepository]',
() {
expect(repositoryImpl, isA<UserRepository>());
},
);
group('getUserById - ', () {
const testModel = UserModel(id: 1, name: 'John Doe');
final testException = Exception('message');
final testFailure = Failure(testException.toString());
test(
'given UserRepositoryImpl '
'when [UserRepositoryImpl.getUserById] is called '
'then return [User] ',
() async {
// Arrange
when(
() => remoteDataSource.fetchUserById(any()),
).thenAnswer((_) async => testModel);
// Act
final result = await repositoryImpl.getUserById('1');
// Assert
expect(
result,
equals(const Right<Failure, User>(testModel)),
);
verify(
() => remoteDataSource.fetchUserById('${testModel.id}'),
).called(1);
verifyNoMoreInteractions(remoteDataSource);
},
);
test(
'given UserRepositoryImpl '
'when [UserRepositoryImpl.getUserById] is called '
'and remote data source call unsuccessful '
'then return [Failure] ',
() async {
// Arrange
when(
() => remoteDataSource.fetchUserById(any()),
).thenThrow(testException);
// Act
final result = await repositoryImpl.getUserById('1');
// Assert
expect(
result,
equals(Left<Failure, User>(testFailure)),
);
verify(
() => remoteDataSource.fetchUserById('${testModel.id}'),
).called(1);
verifyNoMoreInteractions(remoteDataSource);
},
);
});
}
As expected these will fail
Step 2: Green Phase
Now create the repository implementation in the file lib/features/user_profile/data/repositories/user_repository_impl.dart
import 'package:dartz/dartz.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/repositories/user_repository.dart';
class UserRepositoryImpl implements UserRepository {
UserRepositoryImpl(this.remoteDataSource);
final UserRemoteDataSource remoteDataSource;
@override
Future<Either<Failure, User>> getUserById(String id) async {
try {
final userModel = await remoteDataSource.fetchUserById(id);
return Right(userModel);
} on Exception catch (e) {
return Left(Failure(e.toString()));
}
}
}
Import user_repository_impl.dart into the test file
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:my_tdd_app/features/user_profile/data/models/user_model.dart';
import 'package:my_tdd_app/features/user_profile/data/repositories/user_repository_impl.dart'; // New import
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/repositories/user_repository.dart';
class MockUserRemoteDataSource extends Mock implements UserRemoteDataSource {}
void main() {
...
}
Step 3: Refactor Phase
Again, you can refactor the existing tests or create more tests if you want to. We won’t do that here.
Data Sources
We have already created the file for data source but we only have the code for the contract. We will need to write the test before implementing it.
Step 1: Red Phase
In the test folder create the file …/data/data_sources/user_remote_data_source_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_tdd_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:my_tdd_app/features/user_profile/data/models/user_model.dart';
import '../../../../fixtures/fixture_reader.dart';
class MockClient extends Mock implements Client {}
void main() {
late Client client;
late UserRemoteDataSource remoteDataSource;
setUp(() {
client = MockClient();
remoteDataSource = UserRemoteDataSourceImpl(client);
registerFallbackValue(Uri());
});
final testJson = fixture('user.json');
debugPrint('$testJson');
group('fetchUserId - ', () {
test(
'given UserRemoteDataSourceImpl '
'when [UserRemoteDataSourceImpl.fetchUserById] is called '
'then return [UserModel]',
() async {
// Arrange
when(
() => client.get(
any(),
),
).thenAnswer((_) async => Response(testJson, 200));
// Act
final userModel = await remoteDataSource.fetchUserById('1');
// Assert
expect(userModel, isA<UserModel>());
verify(() => client.get(any())).called(1);
verifyNoMoreInteractions(client);
},
);
test(
'given UserRemoteDataSourceImpl '
'when [UserRemoteDataSourceImpl.fetchUserById] is called '
'then return [Exception]',
() async {
// Arrange
final testException = Exception('No user with id found');
when(
() => client.get(
any(),
),
).thenThrow(testException);
// Act
final methodCall = remoteDataSource.fetchUserById;
// Assert
expect(() async => methodCall('1'), throwsA(isA<Exception>()));
verify(() => client.get(any())).called(1);
verifyNoMoreInteractions(client);
},
);
});
}
UserRemoteDataSource will depend on Client from the http package. So in the test we mock it.
Step 2: Green Phase
Create the implementation in the user_remote_data_source.dart.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_tdd_app/features/user_profile/data/data_sources/user_remote_data_source.dart';
import 'package:my_tdd_app/features/user_profile/data/models/user_model.dart';
import '../../../../fixtures/fixture_reader.dart';
class MockClient extends Mock implements Client {}
void main() {
late Client client;
late UserRemoteDataSource remoteDataSource;
setUp(() {
client = MockClient();
remoteDataSource = UserRemoteDataSourceImpl(client);
registerFallbackValue(Uri());
});
final testJson = fixture('user.json');
debugPrint('$testJson');
group('fetchUserId - ', () {
test(
'given UserRemoteDataSourceImpl '
'when [UserRemoteDataSourceImpl.fetchUserById] is called '
'and json response is not empty '
'then return [UserModel]',
() async {
// Arrange
when(
() => client.get(
any(),
),
).thenAnswer((_) async => Response(testJson, 200));
// Act
final userModel = await remoteDataSource.fetchUserById('1');
// Assert
expect(userModel, isA<UserModel>());
verify(() => client.get(any())).called(1);
verifyNoMoreInteractions(client);
},
);
test(
'given UserRemoteDataSourceImpl '
'when [UserRemoteDataSourceImpl.fetchUserById] is called '
'and status is not 200 '
'then return [Exception]',
() async {
// Arrange
final testException = Exception('No user with id found');
when(
() => client.get(
any(),
),
).thenThrow(testException);
// Act
final methodCall = remoteDataSource.fetchUserById;
// Assert
expect(() async => methodCall('1'), throwsA(isA<Exception>()));
verify(() => client.get(any())).called(1);
verifyNoMoreInteractions(client);
},
);
});
}
Tests should now pass
Testing State Management (Optional)
State management is central to any Flutter app, and testing it ensures that your app’s state flows correctly between components. While this step is optional, including it in your TDD process can lead to a more robust app.
Why Test State Management?
- Verify Business Logic: Ensure that changes in state trigger the expected behavior.
- Prevent Bugs: Catch issues like improper state transitions or unintended side effects early.
- Enhance Maintainability: Document expected state behaviors through test cases.
How to Test State Management
We’ll be using Cubit for state management. Our cubit will have four states:
- UserProfileInitial: The initial state before any actions.
- LoadingUserProfile: Indicates the data is being fetched.
- UserProfileLoaded: Contains the fetched user data.
- UserProfileError: Contains error message.
The bloc_test package simplifies writing tests for your state management layer when using Bloc or Cubit.
Step 1: Red Phase
In the test folder create the file …/user_profile/presentation/user_profile_cubit/user_profile_cubit_test.dart
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/use_cases/get_user_profile.dart';
import 'package:bloc_test/bloc_test.dart';
class MockGetUserProfile extends Mock implements GetUserProfile {}
void main() {
late GetUserProfile getUserProfile;
late UserProfileCubit cubit;
setUp(() {
getUserProfile = MockGetUserProfile();
cubit = UserProfileCubit(getUserProfile: getUserProfile);
});
tearDown(() => cubit.close());
// Verify that the initial state is set correctly
test(
'given UserProfileCubit '
'when cubit is instantiated '
'then initial state should be [UserProfileInitial]',
() async {
// Arrange
// Act
// Assert
expect(cubit.state, const UserProfileInitial());
},
);
// Test the method that loads user data
group('loadUserProfile - ', () {
const testUser = User(id: 1, name: 'John Doe');
const testFailure = Failure('message');
blocTest<UserProfileCubit, UserProfileState>(
'given UserProfileCubit '
'when [UserProfileCubit.loadUserProfile] call completed successfully '
'then emit [LoadingUserProfile, UserProfileLoaded]',
build: () {
when(() => getUserProfile(any())).thenAnswer(
(_) async => const Right(testUser),
);
return cubit;
},
act: (cubit) => cubit.loadUserProfile('${testUser.id}'),
expect: () => [
LoadingUserProfile(),
UserProfileLoaded(user: testUser),
],
verify: (cubit) {
verify(() => getUserProfile(any())).called(1);
verifyNoMoreInteractions(getUserProfile);
},
);
blocTest<UserProfileCubit, UserProfileState>(
'given UserProfileCubit '
'when [UserProfileCubit.loadUserProfile] call unsuccessful '
'then emit [LoadingUserProfile, UserProfileError]',
build: () {
when(() => getUserProfile(any())).thenAnswer(
(_) async => const Left(testFailure),
);
return cubit;
},
act: (cubit) => cubit.loadUserProfile('${testUser.id}'),
expect: () => [
LoadingUserProfile(),
UserProfileError(message: testFailure.message),
],
verify: (cubit) {
verify(() => getUserProfile(any())).called(1);
verifyNoMoreInteractions(getUserProfile);
},
);
});
}
Step 2: Green Phase
In the lib folder let’s create the cubit
…/presentation/user_profile_cubit/user_profile_cubit.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/use_cases/get_user_profile.dart';
part 'user_profile_state.dart';
class UserProfileCubit extends Cubit<UserProfileState> {
UserProfileCubit({required GetUserProfile getUserProfile})
: _getUserProfile = getUserProfile,
super(const UserProfileInitial());
final GetUserProfile _getUserProfile;
Future<void> loadUserProfile(String userId) async {
emit(const LoadingUserProfile());
final result = await _getUserProfile(userId);
result.fold(
(failure) => emit(UserProfileError(message: failure.message)),
(user) => emit(UserProfileLoaded(user: user)),
);
}
}
…/presentation/user_profile_cuibt/user_profile_state.dart
part of 'user_profile_cubit.dart';
sealed class UserProfileState extends Equatable {
const UserProfileState();
@override
List<Object?> get props => [];
}
final class UserProfileInitial extends UserProfileState {
const UserProfileInitial();
}
final class LoadingUserProfile extends UserProfileState {
const LoadingUserProfile();
}
final class UserProfileLoaded extends UserProfileState {
const UserProfileLoaded({required this.user});
final User user;
@override
List<Object?> get props => [user];
}
final class UserProfileError extends UserProfileState {
const UserProfileError({required this.message});
final String message;
@override
List<Object?> get props => [message];
}
import cubit file into the cubit test file
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_tdd_app/core/errors/failures.dart';
import 'package:my_tdd_app/features/user_profile/domain/entities/user.dart';
import 'package:my_tdd_app/features/user_profile/domain/use_cases/get_user_profile.dart';
import 'package:bloc_test/bloc_test.dart';
// New Import
import 'package:my_tdd_app/features/user_profile/presentation/user_profile_cubit/user_profile_cubit.dart';
class MockGetUserProfile extends Mock implements GetUserProfile {}
void main() {
...
}
All tests should pass
Step 3: Refactor Phase
You can refactor the cubit tests as you please
Common Pitfalls in TDD and How to Avoid Them
Skipping the Red Stage
- Pitfall: Writing both the test and the implementation simultaneously defeats the purpose of TDD.
- Solution: Always let the test fail first to ensure it’s meaningful.
Overly Broad Tests
- Pitfall: Writing tests that cover too many behaviors makes debugging failures harder.
- Solution: Focus on testing one behavior per test
Over-Mocking
- Pitfall: Mocking everything can make tests too brittle and dependent on implementation details.
- Solution: Mock only external dependencies, not internal ones.
Ignoring Edge Cases
- Pitfall: Failing to test edge cases like empty data, null values, or timeouts.
- Solution: Explicitly write tests for edge cases to ensure robustness.
Test Duplication
- Pitfall: Writing redundant tests for the same behavior increases maintenance overhead.
- Solution: Consolidate overlapping tests to focus on unique behaviors.
Benefits of TDD in Flutter
Improved Code Quality
Writing tests first forces you to design modular, testable, and maintainable code.
Bug Prevention
By testing every feature as it’s implemented, TDD reduces the likelihood of bugs making it to production.
Enhanced Developer Confidence
Comprehensive test coverage makes refactoring and adding new features less risky.
Clearer Requirements
Writing tests helps clarify what each feature should do before implementation.
Cost Efficiency
Catching bugs early in development saves time and resources compared to fixing them later.
Conclusion
Test-Driven Development (TDD) isn’t just a methodology; it’s a mindset that prioritizes clarity, quality, and maintainability. By writing tests first, you gain a deeper understanding of your requirements and catch bugs early, saving time in the long run. In this post, we explored how to implement TDD in Flutter, from setting up your project to testing various app layers.
While TDD may have a learning curve, the benefits—cleaner code, fewer bugs, and more confidence in your application—make it a worthwhile investment for any Flutter developer. Whether you’re building your next personal project or working on production-ready apps, incorporating TDD into your workflow can significantly elevate your development game.
As I move forward I’ll be building a Mental Health Journal (with sentiment analysis) app and applying TDD principles, and other concepts we have discussed in previous posts, to develop its core features step by step. If you’re curious about how all these concepts shapes real-world development, stay tuned for the next posts in my series, where I’ll document this journey in detail.
You can also find the complete code and examples for this blog post in my Github repository. Share your thoughts or questions about TDD in Flutter. I’d love to hear from you!