Tomáš Repčík - 27. 7. 2024

A Practical Approach to Testing with Multiple Dependencies

Tips on how to make reusable and simpler set-up

This article provides a couple of tips on how to avoid recreating complex structures over and over again, so you can spend more time testing.

When you start the project, it is small and easy to maintain. You continue to add more features and the complexity grows.

To make sure, you keep everything intact, you start to implement unit tests, some integration tests and some surface-level UI tests.

However, some people do not pay as much attention to the architecture of test creation as to the tested program itself.

The number of dependencies grows and it coupling of the classes can get out of hand. Afterwards, you spend a solid amount of time just recreating the classes for the test case purposes and not testing at all.

The setup should be straightforward and not problematic. Here are a couple of tips.

Keep in mind, that all tips have to be taken in context of project size, type, language and situation in which you are. There is no silver bullet to solve all the problems with one solution.

Tips for multiple dependencies

Reducing the dependency number at the constructor

This one is more known, but I think it has to be said.

If you find yourself with a code, which contains more than 6 dependencies at a constructor, then I would look for a way how to simplify the class and split the responsibilities further.

Here is an example of a complex class, where we want to handle user’s information. For that, we need some API, database, logger, authorization and others.

class UserProfileService {
  final AuthService authService;
  final DatabaseService databaseService;
  final ApiService apiService;
  final ConfigService configService;
  final NotificationService notificationService;

  UserProfileService({
    required this.authService,
    required this.databaseService,
    required this.apiService,
    required this.configService,
    required this.notificationService,
  });
}

The app does not need to know all the accesses and underlying APIs and databases. Those can be abstracted into services as follows, so the dependencies are distributed.

class AuthHandler {
  final AuthService authService;

  AuthHandler({required this.authService});
}

class DataHandler {
  final DatabaseService databaseService;
  final ApiService apiService;
  final ConfigService configService;

  DataHandler({
    required this.databaseService,
    required this.apiService,
    required this.configService,
  });
}

class NotificationHandler {
  final NotificationService notificationService;

  NotificationHandler({required this.notificationService});
}


class UserProfileLoader {
  final AuthHandler authHandler;
  final DataHandler dataHandler;
  final NotificationHandler notificationHandler;

  UserProfileLoader({
    required this.authHandler,
    required this.dataHandler,
    required this.notificationHandler,
  });
}

Access to data is for sure needed in more places and it automatically becomes reusable. The unit tests for all the components will become more robust and tracking issues will become much easier.

Even though it increases the complexity of dependency injection (DI), because they are more split, on the other hand, the classes could be reused at other parts of the program independently.

Creating factories for dependencies

If you have a class, which does not rely on services, usually you get away with unit tests, which are straightforward by initializing the class itself.

However, classes depending on other services can get quite tricky to create and here comes the importance of factories for all the dependencies.

Many people start to write the initialization of classes into the set-up of the test suite or into the beginning of the test, which is wrong. Creation of integration tests then becomes more tedious and time-consuming.

Even though code duplication is mostly encouraged by the testing framework, it does not apply initialization of testing subjects, which can have one source.

If you have more dependencies, create the factory right away to it.

It is good idea to keep them separate as you will need to provide fakes, mocks and other test doubles for the class.

For example DataHandler from the previous examples can be accompanied by the factory, which provides means to provide your dependencies and also provides fakes, if needed.

class DataHandlerFactory {
  final DatabaseService? realDatabaseService;
  final ApiService? realApiService;
  final ConfigService? realConfigService;

  DataHandlerFactory({
    this.realDatabaseService,
    this.realApiService,
    this.realConfigService,
  });

  factory DataHandler.create() {
    // in memory database for testing
    DatabaseService databaseService = realDatabaseService ?? InMemoryDatabaseService();
    // fake API client to prevent doing direct calls on real API
    ApiService apiService = realApiService ?? FakeApiService();
    // other real services
    ConfigService configService = realConfigService ?? ConfigService();

    return DataHandler(
      databaseService: databaseService,
      apiService: apiService,
      configService: configService,
    );
  }
}

Usage of dependency injection framework

As you can imagine, there can be even too many factories to handle. Here comes to help DI framework. Every language usually has one or has a means of how to pass the references efficiently.

Unit tests and simpler integration tests should not be bothered by the DI framework as it adds another layer of complexity and overhead for running tests.

Small redundancies in initializations adds up and can result in slowing down testing in the long run. I would recommend to have look for DI framework, which supports scoping, so only required set of dependencies is initialized when needed.

For vast integration tests and UI tests the DI framework should do all the heavy lifting.

At the integration test level, the DI framework should provide us with all the means to build testing units.

At the UI test level, the DI should provide means to inject the configuration, data and API responses to execute the test without relying on the backend.

Usage of Fakes/Stubs instead of Mocks

I always prefer to use fakes and stubs in comparison to mocks. The biggest benefit is that you are not bloating your code with mock framework-specific syntax. The library can be abandoned or changed or the licensing can hinder your use case.

With the usage of fakes/stubs, you can easily avoid any of the problems and you have full control over them.

For more information, you can read my dedicated article about it here.

Note to Performance

Always keep in mind the performance of the tests. You can notice telling yourself that: “This initialization, this bigger data chunk does not add up that much”.

However, adding more overhead, dependencies and data can slow down the execution as whole, even at small scales. It piles up.

Slow execution can result in a diminishing frequency of running the tests, which can result in quality issues.

A good rule of thumb is that if you see yourself reluctant to run the test in perspective of the size of the module, package or whole project.

If you need it, you should be still able to run the tests without hesitation.

Conclusion

All the tips depend on the current situation of the project. You have to decide on your own what is the best way to handle growing complexity. Philosophies vary from language to language, but What worked for me so far is:

Thanks for reading and follow for more!

Subscribe for more
LinkedIn GitHub Medium