Best practices for writing unit tests

Hello, Amigos! This is Pavel Gershevich, Mobile Team Lead of the Amiga product development agency. Congratulations, colleagues, we did it — this is the last episode of our multi-part series about testing Flutter applications. And finally, we will analyze 9 best practices for writing unit tests that will help create more effective Unit tests. I will leave the original hereif you happen to know Vietnamese :–)

New releases, useful plugins and libraries, cases and personal experience in our author's telegram channel Flutter. Many. Our community already has 2662 mobile developers, join us!

#1 Each test should only test one scenario.

Typically, each test should have only one assertion (calling the expect or verify functions). A test without an assertion is definitely not considered good. But is it good to have too many assertions?

Many people will say no, but for the author, multiple assertions are not a problem, as it makes the testing thorough and helps debug the test much easier.

However, each test must adhere to the Single Responsibility principle (SOLID). This means that it should only test one test case. For example, when a function needs to be tested addJobat the Act stage – no need to call functions updateJob And removeJob. They can distort the result of the function being tested.

For example, in 6 parts the author called the function expect twice in one test.

// GOOD
group('addJob', () {
  test('should add job to jobMap', () async {
    // before adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 3);

    // Act
    await jobViewModel.addJob(
      jobData: JobData()..id = 4..title="Job 4",
    );

    // after adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 4);
  });
});

// BAD
group('addJob', () {
  test('should add job to jobMap', () async {
    // before adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 3);

    // Act
    await jobViewModel.addJob(
      jobData: JobData()..id = 4..title="Job 4",
    );
    await jobViewModel.updateJob(
      jobData: JobData()..id = 1..title="Job 1 updated",
    );
    await jobViewModel.deleteJob(2);

    // after adding job
    await jobViewModel.getAllJobs();
    expect(jobViewModel.jobMap.length, 3);
  });
});

#2 You need to compare the entire object, not each of its fields separately.

Let's assume that the data from the table JobData need to migrate to table NewJobData.

@collection
class NewJobData {
  Id id = Isar.autoIncrement;

  late String title;
  late String desciption;

  @override
  bool operator ==(Object other) =>
    identical(this, other) ||
    other is NewJobData &&
    runtimeType == other.runtimeType &&
    title == other.title &&
    desciption == other.desciption;

  @override
  int get hashCode => title.hashCode ^ desciption.hashCode;
}

Then you need the code for this migration inside JobData.

@collection
class JobData {
  Id id = Isar.autoIncrement;

  late String title;

  @override
  String toString() {
    return title;
  }

  @override
  bool operator ==(Object other) =>
    identical(this, other) ||
    other is JobData &&
    runtimeType == other.runtimeType &&
    title == other.title;

  @override
  int get hashCode => title.hashCode;

  NewJobData migrate() {
    return NewJobData()
              ..title = title
              ..desciption = '';
  }
}

Now we need to write tests for the function migrate.

test('[v1] data should not be changed after migrating', () {
  final oldJobData = JobData()..title="IT";

  final newJobData = oldJobData.migrate();

  expect(newJobData.title, 'IT');
  expect(newJobData.desciption, '');
});

Of course compare each field title And description also correct, but it is better to compare the entire object.

test('[v2] data should not be changed after migrating', () {
  final oldJobData = JobData()..title="IT";

  final newJobData = oldJobData.migrate();

  expect(
    newJobData,
    NewJobData()
      ..title="IT"
      ..desciption = '',
  );
});

Why? Suppose that one day, several new fields of type postedAt were added to the NewJobData class, but the migration function in the JobData class was not edited. This will cause a bug, and it will be impossible to migrate.

However, despite the fact that there are bugs in the code, when running the test [v1] again, it will pass. This shows that the test [v1] is not enough to show this bug. At the same time, if you run the test [v2]it will crash. It turns out that comparing the entire object instead of comparing each field separately will help find bugs when changes are made.

#3 Don't use if else and loops in tests

It often happens that a developer sees duplicate test code and refactors it. Refactoring is good, but doing it by adding logic, booleans, flags, if else, and for loops to test is not a good idea. There is no guarantee that the logic you wrote in the test is bug-free. If the test fails, there is no way to know whether it was because of bad code or a bad test. And in fact, it will be impossible to write tests to test this logic.

// BAD
test('...', () {
  ...
  for (int i = 0; i < list.length; i++) {
    if (someCondition) {
      expect(someVariable, someValueA);
    } else if (someOtherCondition) {
      expect(someVariable, someValueB);
    } else {
      expect(someVariable, someValueC);
    }
  }
  ...
});

#4 Write tests independently of the code, don't try to modify them to make them pass

Many developers have a habit that when a test fails, they think it is because it is poorly written. So they rewrite the test until it passes.

If the code is correct and the test is wrong, then everything is fine. But if the opposite happens and we try to fix the test, then both of our codes are wrong. This goes against the purpose of testing, because the goal is to test different parts of the code, not to cover every line of it.

#5 Coverage is not important, it is important to write the required number of tests

Regarding testing the isNewJob function in parts 8then this function has only one line of code, so 1 test is enough to cover 100% of the code.

test('isNewJob returns true if job is posted within 7 days', () {
  final job = Job(postedAt: DateTime(2023, 1, 10));

  withClock(Clock.fixed(DateTime(2023, 1, 17)), () {
    expect(job.isNewJob, true);
  });
});

It's not so important what code coverage is, as long as all possible cases are tested, including correct operation, operation with an error, and edge values. For example, when checking the isNewJob function from the example above, the following needs to be tested:

  • When the work was published for 1 day (happy case)

  • When the work was published for 8 days (happy case)

  • When the work was published for 7 days (marginal value)

  • When the work was published today (marginal value)

  • When postedAt greater than the current value because the user changed the time on the device earlier or the date from the API is incorrect (hidden case)

#6 Use Faking to make a fake repository instead of using Mock

// GOOD
class FakeJobRepository extends Fake implements JobRepository {
// Giả sử ban đầu trong DB đã có 3 job
  final jobDataInDb = [
    JobData()..id = 1..title="Job 1",
    JobData()..id = 2..title="Job 2",
    JobData()..id = 3..title="Job 3",
  ];

  @override
  Future<void> addJob(JobData jobData) async {
    jobDataInDb.add(jobData);
  }

  @override
  Future<void> updateJob(JobData jobData) async {
    jobDataInDb.firstWhere((element)
      => element.id == jobData.id).title = jobData.title;
  }

  @override
  Future<void> deleteJob(int id) async {
    jobDataInDb.removeWhere((element) => element.id == id);
  }

  @override
  Future<List<JobData>> getAllJobs() async {
    return jobDataInDb;
  }
}

// BAD
class FakeJobRepository extends Mock implements JobRepository {}

when(() => mockJobRepository.addJob(jobData)).thenAnswer((_) async {});

This was discussed in detail in parts 7.

#7 Call the registerFallbackValue function in setUpAll

// GOOD
setUpAll(() {
  registerFallbackValue(Screen('login'));
});

// BAD
setUp(() {
  registerFallbackValue(Screen('login'));
});

This was discussed in detail in parts 5.

#8 Initialize test objects and Mock objects in the setUp function

// GOOD
late MockSharedPreferences mockSharedPreferences;
late LoginViewModel loginViewModel;

setUp(() {
  mockSharedPreferences = MockSharedPreferences();
  loginViewModel = LoginViewModel(sharedPreferences:  
    mockSharedPreferences);
});

// BAD
final mockSharedPreferences = MockSharedPreferences();
final loginViewModel = LoginViewModel(sharedPreferences: 
  mockSharedPreferences);

This was discussed in detail in parts 4.

№9 Naming convention

There are only 3 rules to remember:

  1. The name of the test file must consist of the name of the file with the code plus a suffix _test.dart.

  2. Folder structure test should repeat the folder structure lib:

  1. Test descriptions (no matter how long) should be understandable to others, so that they can instantly understand what is being tested without reading the Unit test code. This can be done using the formula:

[unit name] ... [should] ... [expected output] ... [when] ... context

This was discussed in detail in Part 2.

Conclusion

That's it! Now you know everything about testing Flutter apps. We hope this material was useful and brought you new knowledge. Have a good code and easy testing!

We stay in touch with you at Flutter.Many.

Similar Posts

Leave a Reply

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