I am developing a hobby project where I try to use DI to get testable code. Until now, I found that it improved both the readability, usability, and testability of the code. However, now I have a situtation where I find that the usability suffers a lot and I think there is some key part of DI that I am missing.
In the initial setup phase of my application, I need to load a project file. In the Application class, the code is quite simple and readable:
void Application::run(int argc, char** argv)
{
m_project.load(argc, argv);
// other stuff following, e.g., run main loop
}
Its constructor receives a ICmdLineProject interface with a void load(int argc, char** argv) function. The concrete implementation now reads a file name from the command line arguments, parses the file, and then applies the stored values:
void FooProject::load(int argc, char** argv)
{
std::string file_name = read_file_name_from_cmd_line(argc, argv);
std::ifstream file(file_name);
IniConfig ini(file);
FooConfig config = parse_ini_config(ini);
m_window_system.create_window(config.m_width, config.m_height);
}
When implementing the unit tests for FooProject::load(), I found that the function does too many things and has many error cases that need to be checked:
- It should throw if the cmd line args can not be parsed.
- It should throw if the file does not exist.
- It should throw if the file can not be parsed as ini file.
- It should throw if the
window_widthandwindow_heightvalues are missing in the ini file.
Additionally, I need to check that create_window() is called with the correct arguments. This last test was easy with the gmock framework. However, if this test fails, the test does not show what part went wrong: Did parsing the cmd line args go wrong? Did parsing the ini file go wrong? Did I swap width and height?
In order to split the responsibilities and simplify testing, I decided to split the class:
CmdLineToFileProject: Reads a file name from the cmd line args and callsload(file_name)on someIFileProjectthat was passed in the constructor.FileToStreamProject: Opens the file as stream and callsload(stream)on someIStreamProject.StreamToIniProject: Creates aIniConfigfrom the stream and callsload(ini)on someIIniProject.IniToFooConfigProject: Creates aFooConfigfrom the ini and callsload(config)on someIFooProject.
Now every class does exactly one thing. It is very easy to test both success and failure cases for each step. The FooProject is now very simple:
void FooProject::load(FooConfig const& f_config)
{
m_window_system.create_window(f_config.m_width, f_config.m_height);
}
Unfortunately, the application initialization now became a lot more complicated. Previously, it was as simple as this:
FooProject project;
Application app(project);
app.run(argc, argv);
Now however, I need to chain all the project classes together:
FooProject p0;
IniToFooConfigProject p1(p0);
StreamToIniProject p2(p1);
FileToStreamProject p3(p2);
CmdLineToFileProject p4(p3);
Application app(p4);
app.run(argc, argv);
I think this is really inconvenient and the users of FooProject and Application are suffering. I just wanted to improve a small method (5 lines) and needed to create 4 new classes and their interfaces. How can you avoid this class chain? This must be a common problem when applying DI techniques. Are there any solutions to this? Did I get the whole application setup wrong?
FooProject::loadbasically sets up the entire program. You want to test the "set up program" function, is this correct? It's almost like trying to unit-testmain.