Mistakes that make writing tests difficult

Hello, Amigos! This is Pavel Gershevich, Mobile Team Lead of the Amiga product development agency. After studying the techniques of writing Unit tests in previous parts It's time to move on to studying the moments when we can't write tests. This means that there are errors in the coding somewhere, which complicates automated testing.

We have merged 2 articles (1, 2), to immediately tell about all the frequently encountered errors when writing code. Let's go!

Hidden text

Ps: new releases in our telegram channel Flutter. Many. Subscribe so you don't miss anything.

Mistake 1: Not using Dependency Injection (DI)

Without using DI, you cannot use Mocking and Stubbing to test a variety of scenarios.

For understanding, let's write 2 examples: one with DI and the other without it.

For example, there is a class Storageas a class dependency Repository.

class Storage {
  String getAccessToken() {
    return 'token';
  }
}

The original author does not mean DI itself by DI, but the correct spelling so that it can be used.

Now let's write the class code Repository using DI.

class Repository {
  final Storage storage;

  Repository({required this.storage});

  bool get isLoggedIn => storage.getAccessToken().isNotEmpty;
}

Without DI class Repository will look like this:

class Repository {
  final storage = Storage();

  bool get isLoggedIn => storage.getAccessToken().isNotEmpty;
}

Now let's write tests for 2 classes Repository.

When using DI you can create a class MockStorage.

class MockStorage extends Mock implements Storage {}

void main() {
  late MockStorage mockStorage;
  late Repository repository;

  setUp(() {
    mockStorage = MockStorage();
    repository = Repository(storage: mockStorage);
  });
}

Next, you can use Stubbing to simulate the function getAccessTokenso that it returns empty or non-empty. This results in 2 different test scenarios:

test('should return true when the access token is not empty', () {
  // Arrange
  when(() => mockStorage.getAccessToken()).thenReturn('access_token');

  // Act
  bool isLoggedIn = repository.isLoggedIn;

 // Assert
  expect(isLoggedIn, true);
});

test('should return false when the access token is empty', () {
  // Arrange
  when(() => mockStorage.getAccessToken()).thenReturn('');

  // Act
  bool isLoggedIn = repository.isLoggedIn;

  // Assert
  expect(isLoggedIn, false);
});

If you don't use DI, you can't replace a class Storage and simulate the function getAccessTokenso there will be only one test scenario.

void main() {
  late Repository repository;

  setUp(() {
    repository = Repository();
  });

  test('should return true when the access token is not empty', () {
    // Act
    bool isLoggedIn = repository.isLoggedIn;

    // Assert
    expect(isLoggedIn, true);
  });
}

In summary, using DI helps you test more test cases.

Mistake 2: Using top-level functions and variables inside the method being tested

Let's say your app calls APIs from 3 different servers: Firebase, Facebook, and a private server. Often, you create global variables for use in Repository classes. Something like this:

final firebaseApiClient = Dio(BaseOptions(baseUrl: 'https://firebase.google.com'));
final appServerApiClient = Dio(BaseOptions(baseUrl: 'https://nals.vn'));

These variables are reused in many functions of the class. Repository.

class Repository {
  Future<String> getMyJob() async {
    final response = await appServerApiClient.request('/me/job');

    return response.toString();
  }

  Future<String> getAllJobs() async {
    final response = await appServerApiClient.request('/jobs');

    return response.toString();
  }
}

If the code was written this way, it would be impossible to test the functions. getMyJob And getAllJobs.

test('getMyJob should return what the API returns', () async {
  final repository = Repository();

  final jobs = await repository.getMyJob();

  expect(jobs, 'IT'); // Откуда я знаю, что API вернет «IT»?
});

Repository depends on global variable appServerApiClientand global variables cannot be substituted and the result returned by the API cannot be imitated. Therefore, it is impossible to know what the API will return in order to pass it to the function expect.

Moreover, when it is not replaced appServerApiClient When running a test, it will make a real request to the API, which will lead to the risk of the test crashing due to server errors with 4xx and 5xx codes.

Now let's refactor this code so that it becomes possible to write tests.

Instead of creating 3 global variables, you need to create 3 classes.

class AppServerApiClient {
  final Dio dio;

  AppServerApiClient() : dio = Dio(BaseOptions(baseUrl: 
    'https://nals.vn'));

  Future<Response> request(String path) async {
    return dio.request(path);
  }
}

class FirebaseApiClient {
  final Dio dio;

  FirebaseApiClient() : dio = Dio(BaseOptions(baseUrl: 
    'https://firebase.google.com'));

  Future<Response> request(String path) async {
    return dio.request(path);
  }
}

class FacebookApiClient {
  ...
}

To avoid code duplication, we create a class BaseApiClient.

class BaseApiClient {
  final String baseUrl;
  final Dio dio;

  BaseApiClient(this.baseUrl) : dio 
    = Dio(BaseOptions(baseUrl: baseUrl));

  Future<Response> request(String path) async {
    return dio.request(path);
  }
}

class AppServerApiClient extends BaseApiClient {
  AppServerApiClient() : super('https://nals.vn');
}

class FirebaseApiClient extends BaseApiClient {
  FirebaseApiClient() : super('https://firebase.google.com');
}

class FacebookApiClient extends BaseApiClient {
  FacebookApiClient() : super('https://facebook.com');
}

Next we implement them in Repository.

class Repository {
  final AppServerApiClient appServerApiClient;
  final FirebaseApiClient firebaseApiClient;
  final FacebookApiClient facebookApiClient;

  Repository({
    required this.appServerApiClient,
    required this.firebaseApiClient,
    required this.facebookApiClient,
  });

  ...
}

Now you can create a Mock object for the API and use the Stubbing technique.

class MockAppServerApiClient extends Mock
  implements AppServerApiClient {}

class MockFirebaseApiClient extends Mock
  implements FirebaseApiClient {}

class MockFacebookApiClient extends Mock
  implements FacebookApiClient {}

...

test('getMyJob should return ', () async {
  // Stub
  when(() => mockAppServerApiClient.request('/me/job')).thenAnswer(
    (_) async => Response(
      requestOptions: RequestOptions(path: '/me/job'),
      data: 'IT',
    ),
  );

  // Act
  final jobs = await repository.getMyJob();

  // Assert
  expect(jobs, 'IT');
  });

Full source code

Mistake 3: Calling a plugin function that uses native code inside the function under test

Often functions are used directly from plugins such as FirebaseAnalytics, FirebaseCrashlytics, FirebaseFirestore and others, inside class functions Repository:

class Repository {
  Future<String> getMyJob() async {
    final response = await    
      FirebaseFirestore.instance.collection('job').doc('me').get();

    return response.data()?['data'] ?? '';
  }
}

This code also violates Error 2, as it is not possible to Mocking the FirebaseFirestore class, which results in the real function being called and the test failing. Also, if a plugin function that uses native code was run in a test environment, the error would be:

MissingPluginException(No implementation found for method someMethodName on channel some_channel_name)

For this case, you will need to create a class in which to wrap the plugin function call. At the same time, it will be very difficult to write tests for this class, so we will try to make its functions as simple and logical as possible.

class FirebaseFirestoreService {
  Future<Map<String, dynamic>?> getMyJob() async {
    final response = await   
      FirebaseFirestore.instance.collection('job').doc('me').get();

    return response.data();
  }
}

The rest of the logic will be written in the class Repository.

class Repository {
  final FirebaseFirestoreService firebaseFirestoreService;

  Repository({required this.firebaseFirestoreService});

  Future<String> getMyJob() async {
    final response = await firebaseFirestoreService.getMyJob();

    return response?['data'] ?? '';
  }
}

Full source code

Now you can write tests for the class Repository.

Note that plugins that only use Dart code may work fine in unit tests. In addition to the above fix, you can turn to other methods Here.

Mistake 4: Not separating logic from UI

Adding logic to widgets that cannot be tested as UI will make that logic difficult to test. For example:

class LoginButton extends StatelessWidget {
  const LoginButton({
    super.key,
    required this.email,
    required this.password,
  });

  final String email;
  final String password;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        login(context, email, password);
      },
      child: const Text('Login'),
    );
  }

  void login(BuildContext context, String email, String password) {
    if (email.isEmpty || password.isEmpty) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text('Email and password are required'),
        ),
      );
    } else {
      Navigator.of(context).pushNamed('home');
    }
  }
}

If the code is like this, it is impossible to write tests for the function. loginsince the class LoginButton — is a widget and cannot be initialized in the test environment.

You need to create another class for logic and separate it from UI.

class LoginViewModel {
  bool login(String email, String password) {
    if (email.isEmpty || password.isEmpty) {
      return false;
    } else {
      return true;
    }
  }
}

In the code above, there is no way to check whether it was shown SnackBar or whether there was a switch to the Home screen. To test Flutter plugin code lines such as Navigator And ScaffoldMessengeryou need to create a wrapper class for such functions:

class AppNavigator {
  final BuildContext context;

  AppNavigator(this.context);

  void showSnackBar(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
      ),
    );
  }

  void pushNamed(String name) {
    Navigator.of(context).pushNamed(name);
  }
}

Now, we need to change the function login.

void login(AppNavigator navigator, String email, String password) {
  if (email.isEmpty || password.isEmpty) {
    navigator.showSnackBar('Email and password are required');
  } else {
    navigator.pushNamed('home');
  }
}

Now you can write tests.

class MockAppNavigator extends Mock implements AppNavigator {}

...

test('navigator.push should be called once when the email and password are not empty', () {
  // Arrange
  String email="ntminh@gmail.com";
  String password = '123';

  // Act
  loginViewModel.login(mockAppNavigator, email, password);

  // Assert
  verifyNever(() => mockAppNavigator.showSnackBar(any()));
  verify(() => mockAppNavigator.pushNamed('home')).called(1);
});

Full source code

This code did not use any popular state management solutions like Riverpod, BLoC, etc. It also did not use MVC, MVP, or MVVM design patterns, so the code was not very clean and had some anti-patterns.

In fact, if you use state management packages like Riverpod, BLoC or patterns like MVC, MVP, MVVM, it will help to separate logic from UI and improve the quality of the code to write tests for it.

Proper use of architectural approaches makes testing easier. So use SOLID and you will be happy!

Mistake 5: Using DateTime.now()

Let's say we need to test a function isNewJob.

class Job {
  final DateTime postedAt;

  Job({required this.postedAt});

  bool get isNewJob => DateTime.now().difference(postedAt).inDays <= 7;
}

January 28, 2024 is the day the author wrote the article, so the test was written with the script exactly a week earlier – January 21, 2024.

The dates used by the original author have been retained.

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

  expect(job.isNewJob, true);
});

However, if you run this test tomorrow, it will fail, since 8 days will have passed since postedAt.

To fix this, you need to add the clock package and replace DateTime.now() on clock.now().

bool get isNewJob => clock.now().difference(postedAt).inDays <= 7;

At the same time, it is necessary to use the function withClockto simulate the current time.

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);
  });
});

Full source code

As you can see, even if you change postedAt for 2023, the result will remain correct. This is due to the fact that the simulation of the current time has changed to use 2023.

Mistake 6: Writing a function that is too big or dividing it into many too small ones

Previously, a function was written that performed many actions on the Splash screen, including:

  1. Getting Remote Config from Firebase.

  2. Checking the application version for forced update.

  3. Checking that the user has launched the application for the first time.

  4. Checking that it is necessary to show the user a recommendation to update and important dialog boxes.

Here is the code:

class UseCaseOutput {
  final Config remoteConfig; // remote config from Firebase
  final bool needForceUpdate; // need force update or not
  final bool isFirstLogin; // is this the first time login?
  final bool recommendUpdateApp; // need to show dialog to recommend update app
  final bool isShowImportantNotice; // need show dialog with important notice

  const UseCaseOutput({
    required this.remoteConfig,
    required this.needForceUpdate,
    required this.isFirstLogin,
    required this.recommendUpdateApp,
    required this.isShowImportantNotice,
  });
}

class FetchRemoteConfigUseCase {
  const FetchRemoteConfigUseCase(this.repository);

  final Repository repository;

  Future<UseCaseOutput> execute() async {
    final remoteConfig = await repository.fetchRemoteConfig();
    final currentAppVersion = _getCurrentAppVersion();
    var matchedVersion = _checkForceUpdate(
      remoteConfig.versionList, 
      currentAppVersion,
    );

    final lastRecommendTime =
        DateTime.tryParse(repository.showRecommendUpdateVersionTime);
    final lastShowImportantNotice = 
        DateTime.tryParse(repository.showImportantNoticeTime);


    return UseCaseOutput(
        remoteConfig: matchedVersion?.config 
            ?? remoteConfig.defaultConfig,
        needForceUpdate: matchedVersion == null,
        isFirstLogin: repository.isFirstLogin,
        recommendUpdateApp: matchedVersion?.config != null
          && matchedVersion!.config.recommendUpdateVersion
            .isRecommendUpdate(lastRecommendTime),
        isShowImportantNotice: matchedVersion?.config != null
          && matchedVersion!.config.importantNotice
            .isShowNotice(lastShowImportantNotice),
    );
  }

  Version _getCurrentAppVersion() {
    final versionName = RegExp(r'\d+')
      .allMatches(repository.currentAppVersion)
      .map((e) => int.tryParse(e.group(0) ?? '0'));

    return Version(
      major: versionName.elementAtOrNull(0) ?? 0,
      minor: versionName.elementAtOrNull(1) ?? 0,
      revision: versionName.elementAtOrNull(2) ?? 0,
      availableFrom: DateTime.now(),
      availableTo: DateTime.now(),
    );
  }

  Version? _checkForceUpdate(
    List<Version> remoteConfigVersions,
    Version currentAppVersion,
  ) {
    Version? currentConfig;
    for (final version in remoteConfigVersions.sortedDescending()) {
      if (version.isEqualWith(currentAppVersion)) {
        if (version.isAvailable) {
          currentConfig = version;
        }
        break;
      }

      if (currentAppVersion.isGreaterThan(version)
          && version.isAvailable) {
        if (currentConfig == null 
            || version.isGreaterThan(currentConfig)) {
          currentConfig = version;
        }
      }
    }

    return currentConfig;
  }
}

class Repository {
  Future<RemoteConfig> fetchRemoteConfig() async
    => const RemoteConfig();

  String get lastRecommendTime => '';

  String get lastShowImportantNotice => '';

  bool get isFirstLogin => false;

  String get currentAppVersion => '1.1.0';
}

Full source code

Writing a method with so much functionality will result in unnecessary code duplication in the tests. For example, you only need to test the check function for a forced update, but you need to use Mocking and Stubbing for other functions that are not related to it.

when(() => _appRepository.fetchRemoteConfig()).thenAnswer((_) 
           => Future.value(remoteConfig));
when(() => _appRepository.currentAppVersion).thenReturn('1.1.0');

// không liên quan đến chức năng force update
when(() => _appRepository.lastRecommendTime).thenReturn('');
when(() => _appRepository.lastShowImportantNotice).thenReturn('');
when(() => _userRepository.isFirstLogin).thenReturn(true);

If you need to test just one scenario, you need to repeat at least 3 lines of code. Whereas when testing many cases, the amount of unnecessary code will be very large.

Moreover, when the code in the function changes lastRecommendTime and you need to rewrite the test, then the test cases for forced update will also be affected. Then you will have to fix many tests that are not related to the function lastRecommendTime.

On the other hand, if you split a function into too many small ones, that's also bad. Because then you'll also have to write a lot of test cases and, more importantly, a lot of small tests, which won't help with quality assurance.

As mentioned in the first part, unit tests focus only on testing a single function. And you can only make sure that each function runs correctly, but there is no guarantee that they will work together as expected.

In general, if you write a large function that combines a lot of functionality or break the function into too small ones, then this will not have a good effect on writing tests in the future.

Conclusion

Above are the common mistakes that are found while writing code and make writing tests more difficult. Hopefully this article will give you more knowledge and experience to better design your code to make writing tests easier and cover more scenarios.

In the next article we will talk about best practices when writing tests.

Subscribe to telegram channel Flutter. Manyso you don't miss the new issue!

Similar Posts

Leave a Reply

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