Test-Driven Development in Flutter: A Step-by-Step Guide

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!

Christian
Christian
Articles: 8

Leave a Reply

Your email address will not be published. Required fields are marked *