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.
- It helps you find bugs even before actual users find them.
- it helps you maintain a scalable code.
- it helps you ensure that an updated code doesn't break any existing functionality.
- 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
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();
}
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: ""));
}
}
}
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: ""),
);
}
}
}
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() {}
and then run:
flutter packages pub run build_runner build --delete-conflicting-outputs
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);
});
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);
});
});
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);
});
});
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);
});
});
}
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);
});
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());
}
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());
}
Repo: Odinote github repo
Hope you enjoyed the read.
Top comments (0)