DEV Community

Odinachi David
Odinachi David

Posted on

Unit Testing in Flutter: Services, Blocs and Sqflite

A simple google search of "Unit testing" will say:

Unit testing is a software development process in which the smallest testable parts of an application, called units, are individually and independently scrutinised for proper operation

well, the quote above is quite self-explanatory, to add to it I'll share a few benefits of testing.

  1. It helps you find bugs even before actual users find them.
  2. it helps you maintain a scalable code.
  3. it helps you ensure that an updated code doesn't break any existing functionality.
  4. it helps you become a better developer👌

Overview

we'll be using a simple note app for the testing, the app uses cubit for state management and Sqflite for storage.

sqlflite is a structured cache database that helps us perform SQL queries on the device cache.

so we'll be writing unit tests for our sqflite, CRUD service and bloc implementations to be sure nothing unexpected is happening on our app.

Dependancies:

install the following dependancies:

dependencies:
  sqflite:
  bloc_test:

dev_dependencies:
  mockito: ^5.2.0
  build_runner: ^2.2.0
  sqflite_common_ffi: ^2.1.1+1
Enter fullscreen mode Exit fullscreen mode

Implementation:

we have two screens on the app and as good practice, each of those screens has to have it's own cubit, so we'll have two cubits one for the home screen which will display all our tasks and another one for the edit screen, where we can edit, delete and create a new task. we also have a service class that manages the entire CRUD operations.

app_service.dart

class TaskService {
  late Database db;

  TaskService._privateConstructor();
  static final TaskService instance = TaskService._privateConstructor();

  Future initialize(String path) async {
    db = await openDatabase(path, version: 1,
        onCreate: (Database db, int version) async {
      return await db.execute(databaseRules);
    });
  }

  Future<Task> insert(Task task) async {
    task.id = await db.insert(tableTodo, task.toMap());
    return task;
  }

  Future<List<Task>?> getAllTask() async {
    await Future.delayed(const Duration(milliseconds: 1000));
    var p = await db.rawQuery('SELECT * FROM $tableTodo');
    var list;
    list = List<Task>.from(p.map((e) => Task.fromMap(e)));
    return list;
  }

  Future<Task?> getTask(int id) async {
    List<Map> maps = await db.query(tableTodo,
        columns: [columnId, columnDone, columnTitle, columnDesc],
        where: '$columnId = ?',
        whereArgs: [id]);
    if (maps.isNotEmpty) {
      return Task.fromMap(maps.first as Map<String, dynamic>);
    }
    return null;
  }

  Future<int?> delete(int id) async {
    return await db.delete(tableTodo, where: '$columnId = ?', whereArgs: [id]);
  }

  Future<int?> update(Task task) async {
    return await db.update(tableTodo, task.toMap(),
        where: '$columnId = ?', whereArgs: [task.id]);
  }

  Future close() async => db.close();
}

Enter fullscreen mode Exit fullscreen mode

home_cubit.dart

class HomeScreenCubit extends Cubit<HomeScreenState> {
  late final TaskService taskService;

  HomeScreenCubit(TaskService service) : super(InitialState()) {
    taskService = service;
  }

  void fetchAllTask() async {
    emit(OnLoading());
    var p = await this.taskService.getAllTask();
    if (p != null) {
      if (p.isNotEmpty) {
        List<Task> _com = [];
        List<Task> _unCom = [];
        for (var element in p) {
          if (element.done == true) {
            _com.add(element);
          } else {
            _unCom.add(element);
          }
        }
        emit(OnSuccess(completedTasks: _com, unCompletedTasks: _unCom));
      } else {
        emit(OnEmpty());
      }
    } else {
      emit(OnFailure(error: ""));
    }
  }

  void updateTask(Task task) async {
    var p = await this.taskService.update(task);
    if (p != null) {
      var p = await this.taskService.getAllTask();
      if (p != null) {
        if (p.isNotEmpty) {
          List<Task> _com = [];
          List<Task> _unCom = [];
          for (var element in p) {
            if (element.done == true) {
              _com.add(element);
            } else {
              _unCom.add(element);
            }
          }
          emit(OnSuccess(completedTasks: _com, unCompletedTasks: _unCom));
        } else {
          emit(OnEmpty());
        }
      }
    } else {
      emit(OnUpdateFailure(error: ""));
    }
  }

  void updateList() async {
    var p = await this.taskService.getAllTask();
    if (p != null) {
      if (p.isNotEmpty) {
        List<Task> _com = [];
        List<Task> _unCom = [];
        for (var element in p) {
          if (element.done == true) {
            _com.add(element);
          } else {
            _unCom.add(element);
          }
        }
        emit(OnSuccess(completedTasks: _com, unCompletedTasks: _unCom));
      } else {
        emit(OnEmpty());
      }
    } else {
      emit(OnFailure(error: ""));
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

edit_screen_cubit.dart

class EditScreenCubit extends Cubit<EditScreenState> {
  late final TaskService taskService;

  EditScreenCubit(TaskService service) : super(InitialEditState()) {
    taskService = service;
  }

  void createTask(
      {TaskService? taskService, required String title, String? desc}) async {
    emit(OnEditLoading());
    Task _t = Task(title: title, desc: desc, done: false);
    await this.taskService.insert(_t);
    emit(
      OnEditSuccess(),
    );
  }

  void updateTask(
      {TaskService? taskService,
      required int id,
      required String title,
      String? desc,
      required bool done}) async {
    emit(
      OnEditLoading(),
    );
    Task _t = Task(title: title, desc: desc, done: done, id: id);
    var p = await this.taskService.update(_t);
    if (p != null) {
      emit(
        OnEditUpdateSuccess(),
      );
    } else {
      emit(
        OnEditUpdateFailure(error: ""),
      );
    }
  }

  void deleteTask({TaskService? taskService, required int taskId}) async {
    emit(
      OnEditLoading(),
    );
    var p = await this.taskService.delete(taskId);
    if (p != null) {
      emit(
        OnEditDeleteSuccess(),
      );
    } else {
      emit(
        OnEditDeleteFailure(error: ""),
      );
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

so we'll first set up the mock services inside your test folder and be sure it works before we begin testing our cubit with the mock service so firstly, let's create a mock TaskService by annotating your app_service_test.dart main function like this:

@GenerateMocks([TaskService])
void main() {}
Enter fullscreen mode Exit fullscreen mode

and then run:

flutter packages pub run build_runner build --delete-conflicting-outputs
Enter fullscreen mode Exit fullscreen mode

this should generate a MockTaskService class on the same directory as your app_service_test.dart

let's set up our dependencies for testing.

  late Database database;
  late MockTaskService taskService;
  Task testTask = Task(id: 1, title: "first ", done: false, desc: "yes");
  List<Task> taskList = List.generate(10, (index) => testTask);
  setUpAll(() async {
    sqfliteFfiInit();
    database = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
    await database.execute(databaseRules);
    taskService = MockTaskService();
    taskService.db = database;
    when(taskService.insert(any)).thenAnswer((_) async => testTask);
    when(taskService.update(any)).thenAnswer((_) async => 1);
    when(taskService.delete(any)).thenAnswer((_) async => 1);
    when(taskService.getTask(any)).thenAnswer((_) async => testTask);
    when(taskService.getAllTask()).thenAnswer((_) async => taskList);
  });

Enter fullscreen mode Exit fullscreen mode

what we've done is to initialise our SQL Database object to databaseFactoryFfi.openDatabase(inMemoryDatabasePath) which is a database simulator for test purposes, and then we have predefined Task objects which we'll be using as input and expected results.
when function is what we expect whenever that function is called.
let's test our Database:



  group('Database Test', () {
    test('sqflite version', () async {
      expect(await database.getVersion(), 0);
    });
    test('add Item to database', () async {
      var i = await database.insert(
          tableTodo, Task(title: "first ", done: false, desc: "yes").toMap());
      var p = await database.query(tableTodo);
      expect(p.length, i);
    });
    test('add three Items to database', () async {
      await database.insert(
          tableTodo, Task(title: "second", done: false, desc: "yes").toMap());
      await database.insert(
          tableTodo, Task(title: "third ", done: false, desc: "yes").toMap());
      await database.insert(
          tableTodo, Task(title: "fourth ", done: false, desc: "yes").toMap());
      var p = await database.query(tableTodo);
      expect(p.length, 4);
    });
    test('update first Item', () async {
      await database.update(tableTodo,
          Task(title: "Changed the first", done: false, desc: "yes").toMap(),
          where: '$columnId = ?', whereArgs: [1]);
      var p = await database.query(tableTodo);
      expect(p.first['title'], "Changed the first");
    });
    test('delete the first Item', () async {
      await database.delete(tableTodo, where: '$columnId = ?', whereArgs: [1]);
      var p = await database.query(tableTodo);
      expect(p.length, 3);
    });
    test('Close db', () async {
      await database.close();
      expect(database.isOpen, false);
    });
  });

Enter fullscreen mode Exit fullscreen mode

so basically what we did above was simulate saving to the db and getting responses based on successful execution.

let's test our services with the db instance above

 group("Service test", () {
    test("create task", () async {
      verifyNever(taskService.insert(testTask));
      expect(await taskService.insert(testTask), testTask);
      verify(taskService.insert(testTask)).called(1);
    });
    test("update task", () async {
      verifyNever(taskService.update(testTask));
      expect(await taskService.update(testTask), 1);
      verify(taskService.update(testTask)).called(1);
    });
    test("delete task", () async {
      verifyNever(taskService.delete(1));
      expect(await taskService.delete(1), 1);
      verify(taskService.delete(1)).called(1);
    });
    test("get task", () async {
      verifyNever(taskService.getTask(1));
      expect(await taskService.getTask(1), testTask);
      verify(taskService.getTask(1)).called(1);
    });
    test("get all task", () async {
      verifyNever(taskService.getAllTask());
      expect(await taskService.getAllTask(), taskList);
      verify(taskService.getAllTask()).called(1);
    });
  });
Enter fullscreen mode Exit fullscreen mode

so we basically used our testable db to test our services and we're sure nothing breaks and everything works as is expected of it. here's the complete app_service_test.dart file.

@GenerateMocks([TaskService])
void main() {
  late Database database;
  late MockTaskService taskService;
  Task testTask = Task(id: 1, title: "first ", done: false, desc: "yes");
  List<Task> taskList = List.generate(10, (index) => testTask);
  setUpAll(() async {
    sqfliteFfiInit();
    database = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath);
    await database.execute(databaseRules);
    taskService = MockTaskService();
    taskService.db = database;
    when(taskService.insert(any)).thenAnswer((_) async => testTask);
    when(taskService.update(any)).thenAnswer((_) async => 1);
    when(taskService.delete(any)).thenAnswer((_) async => 1);
    when(taskService.getTask(any)).thenAnswer((_) async => testTask);
    when(taskService.getAllTask()).thenAnswer((_) async => taskList);
  });

  group('Database Test', () {
    test('sqflite version', () async {
      expect(await database.getVersion(), 0);
    });
    test('add Item to database', () async {
      var i = await database.insert(
          tableTodo, Task(title: "first ", done: false, desc: "yes").toMap());
      var p = await database.query(tableTodo);
      expect(p.length, i);
    });
    test('add three Items to database', () async {
      await database.insert(
          tableTodo, Task(title: "second", done: false, desc: "yes").toMap());
      await database.insert(
          tableTodo, Task(title: "third ", done: false, desc: "yes").toMap());
      await database.insert(
          tableTodo, Task(title: "fourth ", done: false, desc: "yes").toMap());
      var p = await database.query(tableTodo);
      expect(p.length, 4);
    });
    test('update first Item', () async {
      await database.update(tableTodo,
          Task(title: "Changed the first", done: false, desc: "yes").toMap(),
          where: '$columnId = ?', whereArgs: [1]);
      var p = await database.query(tableTodo);
      expect(p.first['title'], "Changed the first");
    });
    test('delete the first Item', () async {
      await database.delete(tableTodo, where: '$columnId = ?', whereArgs: [1]);
      var p = await database.query(tableTodo);
      expect(p.length, 3);
    });
    test('Close db', () async {
      await database.close();
      expect(database.isOpen, false);
    });
  });

  group("Service test", () {
    test("create task", () async {
      verifyNever(taskService.insert(testTask));
      expect(await taskService.insert(testTask), testTask);
      verify(taskService.insert(testTask)).called(1);
    });
    test("update task", () async {
      verifyNever(taskService.update(testTask));
      expect(await taskService.update(testTask), 1);
      verify(taskService.update(testTask)).called(1);
    });
    test("delete task", () async {
      verifyNever(taskService.delete(1));
      expect(await taskService.delete(1), 1);
      verify(taskService.delete(1)).called(1);
    });
    test("get task", () async {
      verifyNever(taskService.getTask(1));
      expect(await taskService.getTask(1), testTask);
      verify(taskService.getTask(1)).called(1);
    });
    test("get all task", () async {
      verifyNever(taskService.getAllTask());
      expect(await taskService.getAllTask(), taskList);
      verify(taskService.getAllTask()).called(1);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Cubit:

Cubit is state management and we'll be testing it to ensure, we're emitting the right state at the right time without skipping a step, so we'll have two test files, one will be home_cubit_test.dart and edit_cubit_test.dart.

home_cubit_test.dart

void main() {
  late HomeScreenCubit mockCubit;
  late MockTaskService mockService;
  late Task testTask = Task(id: 1, title: "first ", done: false, desc: "yes");
  List<Task> taskList = List.generate(10, (index) => testTask);

  setUp(() async {
    mockService = MockTaskService();
    mockCubit = HomeScreenCubit(mockService);
  });
Enter fullscreen mode Exit fullscreen mode

so what we did above was initialise our cubit with MockTaskService, with some predefined data to test with.
here's what the complete test looks like:

void main() {
  late HomeScreenCubit mockCubit;
  late MockTaskService mockService;
  late Task testTask = Task(id: 1, title: "first ", done: false, desc: "yes");
  List<Task> taskList = List.generate(10, (index) => testTask);

  setUp(() async {
    mockService = MockTaskService();
    mockCubit = HomeScreenCubit(mockService);
  });
  group("Home Screen bloc test", () {
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if fetch all works',
      build: () => mockCubit,
      setUp: () =>
          when(mockService.getAllTask()).thenAnswer((_) async => taskList),
      act: (b) => b.fetchAllTask(),
      expect: () => [isA<OnLoading>(), isA<OnSuccess>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if fetch all is empty',
      build: () => mockCubit,
      setUp: () => when(mockService.getAllTask()).thenAnswer((_) async => []),
      act: (b) => b.fetchAllTask(),
      expect: () => [isA<OnLoading>(), isA<OnEmpty>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if fetch all fails',
      build: () => mockCubit,
      setUp: () => when(mockService.getAllTask()).thenAnswer((_) async => null),
      act: (b) => b.fetchAllTask(),
      expect: () => [isA<OnLoading>(), isA<OnFailure>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if update task works',
      build: () => mockCubit,
      setUp: () {
        when(mockService.update(any)).thenAnswer((_) async => testTask.id!);
        when(mockService.getAllTask()).thenAnswer((_) async => taskList);
      },
      act: (b) => b.updateTask(testTask),
      expect: () => [isA<OnSuccess>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if update task is empty',
      build: () => mockCubit,
      setUp: () {
        when(mockService.update(any)).thenAnswer((_) async => testTask.id!);
        when(mockService.getAllTask()).thenAnswer((_) async => []);
      },
      act: (b) => b.updateTask(testTask),
      expect: () => [isA<OnEmpty>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if update task fails',
      build: () => mockCubit,
      setUp: () {
        when(mockService.update(any)).thenAnswer((_) async => null);
        when(mockService.getAllTask()).thenAnswer((_) async => taskList);
      },
      act: (b) => b.updateTask(testTask),
      expect: () => [isA<OnUpdateFailure>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if update all works',
      build: () => mockCubit,
      setUp: () =>
          when(mockService.getAllTask()).thenAnswer((_) async => taskList),
      act: (b) => b.updateList(),
      expect: () => [isA<OnSuccess>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if update all fails',
      build: () => mockCubit,
      setUp: () => when(mockService.getAllTask()).thenAnswer((_) async => null),
      act: (b) => b.updateList(),
      expect: () => [isA<OnFailure>()],
    );
    blocTest<HomeScreenCubit, HomeScreenState>(
      'check if update all is empty',
      build: () => mockCubit,
      setUp: () => when(mockService.getAllTask()).thenAnswer((_) async => []),
      act: (b) => b.updateList(),
      expect: () => [isA<OnEmpty>()],
    );
  });
  tearDown(() => mockCubit.close());
}

Enter fullscreen mode Exit fullscreen mode

bloc_test: this builds the bloc.
build: this initialises the bloc and makes it ready for testing.
setUp: this is all the predefined conditions you want from your methods.
act: this is where you call the bloc method you want to execute.
expect: This is an array of expected states in the sequence you expect them.
tearDown: this function is called after executing all the test cases, this is a typical place to close your blocs, streams etc.

edit_cubit_test.dart

void main() {
  late EditScreenCubit mockCubit;
  late MockTaskService mockService;
  late Task testTask = Task(id: 1, title: "first ", done: false, desc: "yes");

  setUp(() async {
    mockService = MockTaskService();
    mockCubit = EditScreenCubit(mockService);
  });
  group('EditScreenBloc', () {
    blocTest<EditScreenCubit, EditScreenState>(
      'check if create was successful',
      build: () => mockCubit,
      setUp: () =>
          when(mockService.insert(any)).thenAnswer((_) async => testTask),
      act: (b) => b.createTask(title: testTask.title!),
      expect: () => [isA<OnEditLoading>(), isA<OnEditSuccess>()],
    );
    blocTest<EditScreenCubit, EditScreenState>(
      'check if update was successful',
      build: () => mockCubit,
      setUp: () => when(mockService.update(any)).thenAnswer((_) async => 1),
      act: (b) => b.updateTask(
          title: testTask.title!, id: testTask.id!, done: testTask.done!),
      expect: () => [isA<OnEditLoading>(), isA<OnEditUpdateSuccess>()],
    );
    blocTest<EditScreenCubit, EditScreenState>(
      'check if update failed',
      build: () => mockCubit,
      setUp: () => when(mockService.update(any)).thenAnswer((_) async => null),
      act: (b) => b.updateTask(
          title: testTask.title!, id: testTask.id!, done: testTask.done!),
      expect: () => [isA<OnEditLoading>(), isA<OnEditUpdateFailure>()],
    );
    blocTest<EditScreenCubit, EditScreenState>(
      'check if delete was successful',
      build: () => mockCubit,
      setUp: () => when(mockService.delete(any)).thenAnswer((_) async => 1),
      act: (b) => b.deleteTask(taskId: testTask.id!),
      expect: () => [isA<OnEditLoading>(), isA<OnEditDeleteSuccess>()],
    );
    blocTest<EditScreenCubit, EditScreenState>(
      'check if delete failed',
      build: () => mockCubit,
      setUp: () => when(mockService.delete(any)).thenAnswer((_) async => null),
      act: (b) => b.deleteTask(taskId: testTask.id!),
      expect: () => [isA<OnEditLoading>(), isA<OnEditDeleteFailure>()],
    );
  });
  tearDown(() => mockCubit.close());
}
Enter fullscreen mode Exit fullscreen mode

Repo: Odinote github repo

Hope you enjoyed the read.

Top comments (0)